Let a subscribe name the account it lands on

Subscribing went wherever the selected account happened to be, and the
button offering it read the local store first — so under a server account
it claimed a channel was subscribed when only the device knew about it.
The button now asks the active provider and nothing else, except under a
local context, where the local store is that provider and can answer
without a hop.

The channel menu gains a destination list: local, plus every account
whose backend does subscriptions, whose instance is switched on, and
which has a credential on this device. A row names its own target, so it
deliberately does not care whether the selected context can be written
to — that is precisely the case where naming somewhere else is the only
way to subscribe at all. The main button is disabled instead, with the
reason beside it. tvOS reaches the same list through its own button and a
full-screen picker, because a menu cannot be driven with a remote.

Membership is asked for only once the rows are on screen: an unwarmed
provider answers by fetching that account's entire subscription list, and
asking every destination up front is a burst nobody requested. Answers
carry the round they were asked under, and "not asked yet" is kept apart
from "asked, not subscribed" — a checkmark the user acts on must not be a
guess.

SubscriptionService grows the destination-parameterized subscribe,
unsubscribe and membership calls the menu writes through, with the
context-implicit ones left as wrappers over the current context. A
membership answer that outlived its login now says nothing rather than
"no". Building a provider moves behind SubscriptionProviderFactory, which
is what lets a test see which destination a write was routed to without a
server behind it.

The unsubscribe-only screens are untouched: they act on the account being
browsed, which is the account whose list they are showing.
This commit is contained in:
Arkadiusz Fal
2026-09-04 23:18:04 +02:00
parent 3d87aeeaf1
commit 0c5745e4fb
7 changed files with 859 additions and 109 deletions

View File

@@ -28,9 +28,11 @@ struct ChannelView: View {
@State private var isLoading = true
@State private var isLoadingMore = false
@State private var errorMessage: String?
@State private var subscription: Subscription?
@State private var isSubscribed = false
@State private var showingUnsubscribeConfirmation = false
#if os(tvOS)
@State private var showingSubscriptionDestinations = false
#endif
@State private var scrollOffset: CGFloat = 0
@State private var scrollToTop: Bool = false
@State private var cachedHeader: CachedChannelData?
@@ -727,6 +729,7 @@ struct ChannelView: View {
if !isDescriptionScrollLocked {
tvOSSubscribeButton
tvOSSubscriptionDestinationsButton
}
if let description, !description.isEmpty {
@@ -789,9 +792,33 @@ struct ChannelView: View {
.font(.headline)
}
.buttonStyle(.borderedProminent)
.disabled(!allowsCurrentContextMutation)
.focused($isSubscribeFocused)
}
/// The destination list, as its own button: a menu is unusable with a
/// remote, and the primary tap has to stay the current account's toggle.
@ViewBuilder
private var tvOSSubscriptionDestinationsButton: some View {
if let channelToSubscribe, SubscriptionDestinations.areOffered(in: appEnvironment) {
Button {
showingSubscriptionDestinations = true
} label: {
Label(
String(localized: "channel.menu.subscribeOn"),
systemImage: "person.crop.circle.badge.plus"
)
.frame(maxWidth: .infinity, minHeight: 60)
}
.buttonStyle(.bordered)
.fullScreenCover(isPresented: $showingSubscriptionDestinations) {
SubscriptionDestinationPicker(channel: channelToSubscribe) { context, subscribed in
destinationDidToggle(context, subscribed: subscribed)
}
}
}
}
private var tvOSTabButtons: some View {
HStack(spacing: 12) {
ForEach([ChannelTab.videos, .shorts, .streams, .playlists]) { tab in
@@ -1197,6 +1224,23 @@ struct ChannelView: View {
systemImage: isSubscribed ? "person.fill.xmark" : "person.badge.plus"
)
}
.disabled(!allowsCurrentContextMutation)
if !allowsCurrentContextMutation {
Text(String(localized: "channel.subscribe.contextUnavailable"))
}
// Naming a destination is a different decision from the one the
// main button makes, so it survives an unusable current context.
// tvOS reaches the same list through its own button — this menu is
// never presented there.
#if !os(tvOS)
if let channelToSubscribe {
SubscriptionDestinationMenu(channel: channelToSubscribe) { context, subscribed in
destinationDidToggle(context, subscribed: subscribed)
}
}
#endif
// Notifications toggle (only visible when subscribed)
if isSubscribed {
@@ -1222,6 +1266,29 @@ struct ChannelView: View {
// MARK: - Subscription Actions
/// Whether the main button may write to the account being browsed.
///
/// Local always may. A server account may only while it is usable: a
/// subscribe that cannot reach its account must be refused rather than
/// land in local data the user did not choose.
private var allowsCurrentContextMutation: Bool {
guard let appEnvironment else { return false }
guard !appEnvironment.accountsManager.currentContext.isLocal else { return true }
return appEnvironment.dataContextValidator.allowsServerMutations
}
/// The channel as it must be stored: the id carries the source this view
/// was opened for, which an extracted channel does not report itself.
private var channelToSubscribe: Channel? {
guard let channel else { return nil }
return Channel(
id: ChannelID(source: source, channelID: channel.id.channelID),
name: channel.name,
subscriberCount: channel.subscriberCount,
thumbnailURL: channel.thumbnailURL
)
}
private func toggleSubscription() {
if isSubscribed {
showingUnsubscribeConfirmation = true
@@ -1231,26 +1298,19 @@ struct ChannelView: View {
}
private func subscribe() async {
guard let channel,
guard let channelToSubscribe,
let subscriptionService = appEnvironment?.subscriptionService,
let dataManager = appEnvironment?.dataManager else { return }
let author = Author(
id: channel.id.channelID,
name: channel.name,
thumbnailURL: channel.thumbnailURL,
subscriberCount: channel.subscriberCount
)
do {
try await subscriptionService.subscribe(to: author, source: source)
try await subscriptionService.subscribe(to: channelToSubscribe)
// Set default notification preference for new subscription
let defaultNotifications = appEnvironment?.settingsManager.defaultNotificationsForNewChannels ?? false
if defaultNotifications {
dataManager.setNotificationsEnabled(true, for: channel.id.channelID)
dataManager.setNotificationsEnabled(true, for: channelToSubscribe.id.channelID)
}
isSubscribed = true
refreshSubscription()
} catch {
@@ -1261,6 +1321,21 @@ struct ChannelView: View {
}
}
/// A destination row toggled something. Only the account on screen changes
/// what the main button says, and notification preferences are local and
/// keyed by channel, so they follow the same rule.
private func destinationDidToggle(_ context: DataContext, subscribed: Bool) {
guard context == appEnvironment?.accountsManager.currentContext else { return }
if subscribed,
appEnvironment?.settingsManager.defaultNotificationsForNewChannels == true,
let channelID = channelToSubscribe?.id.channelID {
appEnvironment?.dataManager.setNotificationsEnabled(true, for: channelID)
}
refreshSubscription()
}
private func unsubscribe() {
guard let channel else { return }
@@ -1268,7 +1343,6 @@ struct ChannelView: View {
do {
try await appEnvironment?.subscriptionService.unsubscribe(from: channel.id.channelID)
isSubscribed = false
subscription = nil
} catch {
appEnvironment?.toastManager.showError(
String(localized: "channel.unsubscribe.error.title"),
@@ -2097,13 +2171,7 @@ struct ChannelView: View {
isLoading = true
errorMessage = nil
// Load subscription state: optimistic from the local store, then
// corrected by the active provider (server accounts differ from local)
subscription = appEnvironment.dataManager.subscription(for: channelID)
isSubscribed = subscription != nil
Task {
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: channelID)
}
refreshSubscription()
// Load cached header data for immediate display
cachedHeader = CachedChannelData.load(for: channelID, using: appEnvironment.dataManager)
@@ -2214,11 +2282,7 @@ struct ChannelView: View {
isLoading = false
// Check subscription status using extracted channel ID
subscription = appEnvironment.dataManager.subscription(for: fetchedChannel.id.channelID)
isSubscribed = subscription != nil
Task {
isSubscribed = await appEnvironment.subscriptionService.isSubscribed(to: fetchedChannel.id.channelID)
}
refreshSubscription()
}
} catch let error as APIError {
await MainActor.run {
@@ -2248,12 +2312,20 @@ struct ChannelView: View {
}
}
/// Re-reads the button's state from the account being browsed.
///
/// The local store is only consulted when it *is* the active context: under
/// a server account it holds another account's channels, and reading it
/// first would show the wrong button until the provider answered.
private func refreshSubscription() {
// Use loaded channel's ID if available (important for external channels
// where navigation channelID is the URL, not the actual channel ID)
let effectiveChannelID = channel?.id.channelID ?? channelID
subscription = appEnvironment?.dataManager.subscription(for: effectiveChannelID)
isSubscribed = subscription != nil
if appEnvironment?.dataContextValidator.effectiveContext.isLocal == true {
isSubscribed = appEnvironment?.dataManager.subscription(for: effectiveChannelID) != nil
}
Task {
if let service = appEnvironment?.subscriptionService {
isSubscribed = await service.isSubscribed(to: effectiveChannelID)

View File

@@ -0,0 +1,265 @@
//
// SubscriptionDestinationMenu.swift
// Yattee
//
// Subscribing somewhere other than the account currently being browsed.
//
import SwiftUI
// MARK: - Membership
/// What is known about a channel's subscription on each destination.
///
/// "Not asked yet" and "asked, not subscribed" are different rows, so the
/// unknown state is kept rather than collapsed into `false`. Answers carry the
/// round they were asked under: a channel change or a second look at the menu
/// starts a new round, and a reply from the old one is dropped instead of being
/// shown against the wrong channel.
struct SubscriptionDestinationMembership: Equatable {
private var known: [DataContext: Bool] = [:]
private(set) var generation = 0
/// Forgets every answer and opens a new round.
mutating func invalidate() {
known.removeAll()
generation += 1
}
/// Records an answer from `generation`.
///
/// A `nil` answer says nothing — the destination has no provider, or the
/// logins were replaced while it was in flight — and must not be stored as
/// "not subscribed".
mutating func record(_ isSubscribed: Bool?, for context: DataContext, generation: Int) {
guard generation == self.generation, let isSubscribed else { return }
known[context] = isSubscribed
}
/// nil while nothing is known, which renders as neither state.
func isSubscribed(on context: DataContext) -> Bool? { known[context] }
}
// MARK: - Destinations
/// Where a channel can be subscribed.
enum SubscriptionDestinations {
/// Local plus every account a subscribe could actually land on.
///
/// The same three things the write layer insists on — the backend must do
/// subscriptions, the instance must be one this app still talks to, and
/// there must be a credential to talk with. Account *status* deliberately
/// is not among them: a stale "rejected" would hide a login that works, and
/// a failed write says so with a toast. Neither is the selected context's
/// availability — a row names its own target, so it keeps working when the
/// account being browsed cannot be reached.
@MainActor
static func available(in appEnvironment: AppEnvironment?) -> [DataContext] {
guard let appEnvironment else { return [.local] }
let accounts = appEnvironment.accountsManager.accounts.filter { account in
appEnvironment.accountsManager.capabilities(for: account)?.supportsSubscriptions == true
&& appEnvironment.accountsManager.instance(for: account)?.isEnabled == true
&& appEnvironment.accountCredentialsManager(for: account)?.isLoggedIn(for: account) == true
}
return [.local] + accounts.map { DataContext.account($0.id) }
}
/// Whether naming a destination is a choice worth offering.
///
/// With only local to offer there is normally nothing the main subscribe
/// button does not already do — unless that button is refusing, because the
/// selected account cannot be written to. Local is then exactly the
/// alternative the user is looking for.
@MainActor
static func areOffered(in appEnvironment: AppEnvironment?) -> Bool {
if available(in: appEnvironment).count > 1 { return true }
guard let appEnvironment else { return false }
return !appEnvironment.accountsManager.currentContext.isLocal
&& !appEnvironment.dataContextValidator.allowsServerMutations
}
}
// MARK: - Rows
/// One row per place the channel can be subscribed, with the membership known
/// there.
///
/// Shared by the menu and the tvOS picker so a destination that is offered in
/// one is offered — and toggled — identically in the other.
struct SubscriptionDestinationRows: View {
@Environment(\.appEnvironment) private var appEnvironment
/// The channel as it must be stored, with the id the calling view resolved.
let channel: Channel
/// The destination that was toggled and whether it is now subscribed, for
/// the view that owns the main subscribe button.
var onToggled: ((DataContext, Bool) -> Void)?
/// Called after any tap, for presentations that have to close themselves.
var onSelect: (() -> Void)?
@State private var membership = SubscriptionDestinationMembership()
@State private var busy: Set<DataContext> = []
var body: some View {
ForEach(destinations, id: \.self) { context in
Button {
toggle(context)
onSelect?()
} label: {
rowLabel(for: context)
}
.disabled(busy.contains(context))
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
// Membership is only asked for once the rows are on screen: an unwarmed
// provider answers by fetching that account's whole subscription list,
// which is far too much to spend on a menu nobody opened.
.task(id: channel.id) {
await loadMembership()
}
}
@ViewBuilder
private func rowLabel(for context: DataContext) -> some View {
let title = DataContextSwitcherLabels.title(for: context, in: appEnvironment)
if membership.isSubscribed(on: context) == true {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
private var destinations: [DataContext] {
SubscriptionDestinations.available(in: appEnvironment)
}
// MARK: - Membership
private func loadMembership() async {
guard let service = appEnvironment?.subscriptionService else { return }
membership.invalidate()
let generation = membership.generation
let channelID = channel.id.channelID
// Serially rather than in a group: each cold destination answers by
// fetching its account's whole list, and firing every instance at once
// is a burst the user never asked for.
for context in destinations {
let subscribed = await service.isSubscribed(to: channelID, in: context)
membership.record(subscribed, for: context, generation: generation)
}
}
// MARK: - Toggle
private func toggle(_ context: DataContext) {
guard let appEnvironment else { return }
// Unknown counts as not subscribed: the row shows no checkmark, so
// subscribing is what the user just asked for.
let wasSubscribed = membership.isSubscribed(on: context) == true
let generation = membership.generation
let channelID = channel.id.channelID
busy.insert(context)
Task {
do {
if wasSubscribed {
try await appEnvironment.subscriptionService.unsubscribe(from: channelID, in: context)
} else {
try await appEnvironment.subscriptionService.subscribe(to: channel, in: context)
}
membership.record(!wasSubscribed, for: context, generation: generation)
onToggled?(context, !wasSubscribed)
} catch {
appEnvironment.toastManager.showError(
String(
localized: wasSubscribed
? "channel.unsubscribe.error.title"
: "channel.subscribe.error.title"
),
subtitle: error.localizedDescription
)
}
busy.remove(context)
}
}
}
// MARK: - Menu
#if !os(tvOS)
/// The destination submenu, for placing inside the channel menu.
struct SubscriptionDestinationMenu: View {
@Environment(\.appEnvironment) private var appEnvironment
let channel: Channel
var onToggled: ((DataContext, Bool) -> Void)?
var body: some View {
if SubscriptionDestinations.areOffered(in: appEnvironment) {
Menu {
SubscriptionDestinationRows(channel: channel, onToggled: onToggled)
} label: {
Label(
String(localized: "channel.menu.subscribeOn"),
systemImage: "person.crop.circle.badge.plus"
)
}
}
}
}
#endif
// MARK: - tvOS picker
#if os(tvOS)
/// The tvOS destination list, presented in a `fullScreenCover`: menus are
/// unusable there, and a sheet is a card too small to read.
struct SubscriptionDestinationPicker: View {
@Environment(\.dismiss) private var dismiss
let channel: Channel
var onToggled: ((DataContext, Bool) -> Void)?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack(alignment: .leading, spacing: 24) {
Text(String(localized: "channel.menu.subscribeOn"))
.font(.title2.bold())
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// The picker stays open across a toggle: checkmarks are
// the whole point of the list, and closing it would hide
// the change the user just made.
SubscriptionDestinationRows(channel: channel, onToggled: onToggled)
}
}
Button(String(localized: "common.close")) {
dismiss()
}
.buttonStyle(TVSettingsButtonStyle())
}
.frame(maxWidth: 900)
.padding(.horizontal, 80)
.padding(.vertical, 60)
}
}
}
#endif