mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
A handful of call sites built their own InvidiousAPI or PipedAPI over the shared HTTP client, which quietly opted them out of everything an instance may require of a request: the reverse-proxy basic auth, the certificate policy, and the User-Agent some instances whitelist and answer 401 without. The feed on an instance behind a proxy could load in one place and fail in another for no reason the user could see. They now all go through the service that already configures a client per instance. Login was the site that had noticed. It reproduced the basic-auth handling itself, including the header Invidious' login needs passed explicitly because that POST runs on its own session to read the SID out of a cookie. That logic moves into the service verbatim rather than being dropped: the view now asks for a credential and is told nothing about how it was obtained. Paging is the one thing the service could not already express. Invidious feeds have pages and Piped feeds do not, so the paged variant says so in its return value instead of leaving each caller to know. The browse view's subscriptions arrive as channels now rather than as raw Invidious rows, so the avatar enrichment works over channels — a fetched or cached avatar still wins over whatever the listing carried, which is the precedence the conversion applied before.
310 lines
10 KiB
Swift
310 lines
10 KiB
Swift
//
|
|
// ImportSubscriptionsView.swift
|
|
// Yattee
|
|
//
|
|
// View for importing subscriptions from an Invidious or Piped instance to local storage.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ImportSubscriptionsView: View {
|
|
let instance: Instance
|
|
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
@State private var channels: [Channel] = []
|
|
@State private var subscribedChannelIDs: Set<String> = []
|
|
@State private var isLoading = true
|
|
@State private var error: Error?
|
|
@State private var showAddAllConfirmation = false
|
|
|
|
// MARK: - Accessibility Identifiers
|
|
|
|
private enum AccessibilityID {
|
|
static let view = "import.subscriptions.view"
|
|
static let loadingIndicator = "import.subscriptions.loading"
|
|
static let errorMessage = "import.subscriptions.error"
|
|
static let emptyState = "import.subscriptions.empty"
|
|
static let list = "import.subscriptions.list"
|
|
static func row(_ channelID: String) -> String {
|
|
"import.subscriptions.row.\(channelID)"
|
|
}
|
|
static func addButton(_ channelID: String) -> String {
|
|
"import.subscriptions.add.\(channelID)"
|
|
}
|
|
static func subscribedIndicator(_ channelID: String) -> String {
|
|
"import.subscriptions.subscribed.\(channelID)"
|
|
}
|
|
static let addAllButton = "import.subscriptions.addAll"
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
content
|
|
#if !os(tvOS)
|
|
.navigationTitle(String(localized: "import.subscriptions.title"))
|
|
#endif
|
|
.accessibilityIdentifier(AccessibilityID.view)
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
#endif
|
|
.toolbar {
|
|
if !unsubscribedChannels.isEmpty {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showAddAllConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "import.subscriptions.addAll"), systemImage: "plus.circle")
|
|
}
|
|
.accessibilityIdentifier(AccessibilityID.addAllButton)
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "import.subscriptions.addAllConfirmation \(unsubscribedChannels.count)"),
|
|
isPresented: $showAddAllConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "import.subscriptions.addAll")) {
|
|
addAllSubscriptions()
|
|
}
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
.task {
|
|
await loadSubscriptions()
|
|
}
|
|
}
|
|
|
|
// MARK: - Content Views
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if isLoading {
|
|
loadingView
|
|
} else if let error {
|
|
errorView(error)
|
|
} else if channels.isEmpty {
|
|
emptyView
|
|
} else {
|
|
listView
|
|
}
|
|
}
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
Text(String(localized: "import.subscriptions.loading"))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.accessibilityIdentifier(AccessibilityID.loadingIndicator)
|
|
}
|
|
|
|
private func errorView(_ error: Error) -> some View {
|
|
ContentUnavailableView {
|
|
Label(String(localized: "import.subscriptions.error"), systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(error.localizedDescription)
|
|
} actions: {
|
|
Button(String(localized: "common.retry")) {
|
|
Task { await loadSubscriptions() }
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.accessibilityIdentifier(AccessibilityID.errorMessage)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
ContentUnavailableView(
|
|
String(localized: "import.subscriptions.emptyTitle"),
|
|
systemImage: "person.2.slash",
|
|
description: Text(String(localized: "import.subscriptions.emptyDescription"))
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.accessibilityIdentifier(AccessibilityID.emptyState)
|
|
}
|
|
|
|
private var listView: some View {
|
|
List {
|
|
ForEach(channels) { channel in
|
|
subscriptionRow(channel)
|
|
.accessibilityIdentifier(AccessibilityID.row(channel.id.channelID))
|
|
}
|
|
}
|
|
.opaqueSettingsFormBackground()
|
|
.accessibilityIdentifier(AccessibilityID.list)
|
|
}
|
|
|
|
// MARK: - Row View
|
|
|
|
@ViewBuilder
|
|
private func subscriptionRow(_ channel: Channel) -> some View {
|
|
HStack(spacing: 12) {
|
|
// Channel avatar
|
|
if let avatarURL = channel.thumbnailURL {
|
|
AsyncImage(url: avatarURL) { image in
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
Image(systemName: "person.circle.fill")
|
|
.resizable()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(width: 32, height: 32)
|
|
.clipShape(Circle())
|
|
} else {
|
|
Image(systemName: "person.circle.fill")
|
|
.resizable()
|
|
.frame(width: 32, height: 32)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Name
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 4) {
|
|
Text(channel.name)
|
|
.lineLimit(1)
|
|
|
|
if channel.isVerified {
|
|
Image(systemName: "checkmark.seal.fill")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if let subscriberCount = channel.formattedSubscriberCount {
|
|
Text(subscriberCount)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Add/Subscribed indicator
|
|
if subscribedChannelIDs.contains(channel.id.channelID) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
.imageScale(.large)
|
|
.accessibilityIdentifier(AccessibilityID.subscribedIndicator(channel.id.channelID))
|
|
} else {
|
|
Button {
|
|
addSubscription(channel)
|
|
} label: {
|
|
Image(systemName: "plus.circle")
|
|
.imageScale(.large)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityIdentifier(AccessibilityID.addButton(channel.id.channelID))
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var unsubscribedChannels: [Channel] {
|
|
channels.filter { !subscribedChannelIDs.contains($0.id.channelID) }
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func loadSubscriptions() async {
|
|
guard let appEnvironment,
|
|
let credential = appEnvironment.credential(forInstance: instance) else {
|
|
error = ImportError.notLoggedIn
|
|
isLoading = false
|
|
return
|
|
}
|
|
|
|
isLoading = true
|
|
error = nil
|
|
|
|
do {
|
|
guard instance.type == .invidious || instance.type == .piped else {
|
|
throw ImportError.notSupported
|
|
}
|
|
|
|
let fetchedChannels = try await appEnvironment.contentService.subscriptions(
|
|
for: instance,
|
|
credential: credential
|
|
)
|
|
|
|
channels = fetchedChannels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
|
|
// Get already-subscribed channel IDs
|
|
subscribedChannelIDs = Set(
|
|
appEnvironment.dataManager.subscriptions().map(\.channelID)
|
|
)
|
|
|
|
isLoading = false
|
|
} catch {
|
|
self.error = error
|
|
isLoading = false
|
|
}
|
|
}
|
|
|
|
private func addSubscription(_ channel: Channel) {
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
dataManager.subscribe(to: channel)
|
|
subscribedChannelIDs.insert(channel.id.channelID)
|
|
|
|
appEnvironment?.toastManager.showSuccess(String(localized: "import.subscriptions.added.title"))
|
|
}
|
|
|
|
private func addAllSubscriptions() {
|
|
guard let dataManager = appEnvironment?.dataManager else { return }
|
|
|
|
let toAdd = unsubscribedChannels
|
|
for channel in toAdd {
|
|
dataManager.subscribe(to: channel)
|
|
subscribedChannelIDs.insert(channel.id.channelID)
|
|
}
|
|
|
|
appEnvironment?.toastManager.showSuccess(
|
|
String(localized: "import.subscriptions.added.title"),
|
|
subtitle: String(localized: "import.subscriptions.count.subtitle \(toAdd.count)")
|
|
)
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum ImportError: LocalizedError {
|
|
case notLoggedIn
|
|
case notSupported
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notLoggedIn:
|
|
return String(localized: "import.subscriptions.notLoggedIn")
|
|
case .notSupported:
|
|
return String(localized: "import.subscriptions.notSupported")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Invidious") {
|
|
NavigationStack {
|
|
ImportSubscriptionsView(
|
|
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
|
|
)
|
|
.appEnvironment(.preview)
|
|
}
|
|
}
|
|
|
|
#Preview("Piped") {
|
|
NavigationStack {
|
|
ImportSubscriptionsView(
|
|
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
|
|
)
|
|
.appEnvironment(.preview)
|
|
}
|
|
}
|