Files
yattee/Yattee/Views/Settings/ImportSubscriptionsView.swift
Arkadiusz Fal b3c4216013 Let every request reach an instance the same way
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.
2026-09-02 23:53:33 +02:00

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)
}
}