From 794b2b0181b1976ec58ce999768150bb98327366 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 19 Sep 2026 23:58:24 +0200 Subject: [PATCH] Let accounts be set inactive without signing them out An inactive account keeps its credential and syncs like a rename, but drops out of every picker and cannot be used until activated again. Deactivating the selected account switches to the local context as a published choice, so other devices follow instead of falling back. --- Yattee/Core/AccountsManager.swift | 47 +++++++++++++++++-- Yattee/Localizable.xcstrings | 44 +++++++++++++++++ Yattee/Models/Account.swift | 23 +++++++++ .../CloudKit/CloudKitConflictResolver.swift | 6 +++ .../CloudKit/CloudKitRecordMapper.swift | 4 ++ .../Subscriptions/DataContextValidator.swift | 21 ++++++++- .../Views/Components/AccountStatusBadge.swift | 2 + .../Components/DataContextSwitcherMenu.swift | 2 +- .../DataContextUnavailableBanner.swift | 3 ++ Yattee/Views/Settings/AccountsListView.swift | 15 ++++++ 10 files changed, 160 insertions(+), 7 deletions(-) diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift index a97d05e5..f8fea004 100644 --- a/Yattee/Core/AccountsManager.swift +++ b/Yattee/Core/AccountsManager.swift @@ -29,6 +29,11 @@ enum AccountStatus: Hashable, Sendable { /// turned on again — but nothing may be read from or written to it /// meanwhile, so no surface may present the account as usable. case instanceDisabled + /// The user set the account itself to inactive. Like `instanceDisabled` + /// this is not a credential state — the login is untouched and comes back + /// the moment the account is activated again — and only `displayStatus` + /// ever reports it. + case inactive /// The server could not be reached. Never invalidates a credential. case unreachable } @@ -189,9 +194,11 @@ final class AccountsManager { /// Accounts that can be selected as the current context right now. /// /// Excludes accounts still waiting for their instance: showing a context - /// the app cannot actually resolve would be a dead end. + /// the app cannot actually resolve would be a dead end. Inactive accounts + /// are excluded too — the user set them aside, and the pickers must not + /// keep offering what was deliberately switched off. var selectableAccounts: [Account] { - accounts.filter { capabilities(for: $0)?.isSelectableContext == true } + accounts.filter { $0.isActive && capabilities(for: $0)?.isSelectableContext == true } } /// Accounts whose instance is gone. @@ -252,6 +259,31 @@ final class AccountsManager { NotificationCenter.default.post(name: .accountsDidChange, object: nil) } + /// Sets whether the account is active, syncing the change like a rename. + /// + /// A write that changes nothing is dropped for the same reason + /// `updateDisplayName` drops one: every real write stamps `updatedAt`, and + /// under last-write-wins an idle stamp would overwrite a peer's genuine + /// edit that has not arrived yet. + func updateIsActive(_ isActive: Bool, for accountID: UUID) { + guard let index = accounts.firstIndex(where: { $0.id == accountID }) else { return } + guard accounts[index].isActive != isActive else { return } + accounts[index].isActive = isActive + accounts[index].updatedAt = Date() + pendingUploads.insert(accountID) + save() + NotificationCenter.default.post(name: .accountsDidChange, object: nil) + + // Deactivating the selected account moves the selection to local — as + // a real choice, not a repair: the user just said "stop using this", + // and unlike a deletion there is no ambiguity for a sibling to settle. + // Publishing it keeps every device's selection off the account the + // user set aside. + if !isActive, storedContext.context == .account(accountID) { + setContext(.local) + } + } + /// Removes an account and everything keyed to it. /// /// The deletion is recorded in the outbox before anything else so that a @@ -417,6 +449,7 @@ final class AccountsManager { refreshStatusesForRepair?() let sibling = accounts(forInstance: instanceID) + .filter(\.isActive) .filter { status(for: $0.id) == .signedIn } .filter { capabilities(for: $0)?.isSelectableContext == true } // Ordered so the answer does not depend on which order the accounts @@ -875,6 +908,7 @@ final class AccountsManager { /// app claiming health it will not deliver. func displayStatus(for account: Account) -> AccountStatus { guard instance(for: account)?.isEnabled != false else { return .instanceDisabled } + guard account.isActive else { return .inactive } return status(for: account.id) } @@ -1195,8 +1229,13 @@ final class AccountsManager { // path, so it is marked dirty and ordered by `updatedAt` like any other // change — nothing else about the record is taken. if let existing = self.account(id: account.id) { - if account.updatedAt > existing.updatedAt, account.displayName != existing.displayName { - updateDisplayName(account.displayName, for: account.id) + if account.updatedAt > existing.updatedAt { + if account.displayName != existing.displayName { + updateDisplayName(account.displayName, for: account.id) + } + if account.isActive != existing.isActive { + updateIsActive(account.isActive, for: account.id) + } } return .mergedIntoExisting } diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 00415fbe..dcf8d912 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -139,6 +139,17 @@ }, "1.2M subscribers" : { + }, + "accounts.activate" : { + "comment" : "Action putting an inactive account back in use", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activate" + } + } + } }, "accounts.addAccount" : { "localizations" : { @@ -194,6 +205,17 @@ } } }, + "accounts.deactivate" : { + "comment" : "Action setting an account inactive without signing it out", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deactivate" + } + } + } + }, "accounts.empty.description" : { "localizations" : { "en" : { @@ -439,6 +461,17 @@ } } }, + "accounts.status.inactive" : { + "comment" : "Status of an account the user set to inactive", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inactive" + } + } + } + }, "accounts.status.instanceDisabled" : { "comment" : "Status of an account whose source has been switched off", "localizations" : { @@ -18159,6 +18192,17 @@ } } }, + "subscriptions.banner.accountInactive" : { + "comment" : "Banner shown while the selected account cannot be used yet", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This account is set to inactive. Showing local data." + } + } + } + }, "subscriptions.banner.checking" : { "comment" : "Banner shown while the selected account cannot be used yet", "localizations" : { diff --git a/Yattee/Models/Account.swift b/Yattee/Models/Account.swift index e709fb85..1835aa8f 100644 --- a/Yattee/Models/Account.swift +++ b/Yattee/Models/Account.swift @@ -28,6 +28,14 @@ struct Account: Identifiable, Codable, Hashable, Sendable { /// User-set label. The only name that syncs. var displayName: String? + /// Whether the user wants this account in use at all. + /// + /// Deactivating is deliberately not a credential state: the login stays in + /// the Keychain untouched, and reactivating restores whatever it really + /// was. While inactive the account cannot be switched to and the selection + /// falls back to local — the account-level twin of a switched-off source. + var isActive: Bool + let dateAdded: Date /// Bumped on every user edit — supplies the ordering for conflict @@ -38,16 +46,31 @@ struct Account: Identifiable, Codable, Hashable, Sendable { id: UUID = UUID(), instanceID: UUID, displayName: String? = nil, + isActive: Bool = true, dateAdded: Date = Date(), updatedAt: Date = Date() ) { self.id = id self.instanceID = instanceID self.displayName = displayName + self.isActive = isActive self.dateAdded = dateAdded self.updatedAt = updatedAt } + /// Decoded by hand for one reason: accounts persisted before `isActive` + /// existed carry no such key, and they are all active — that was the only + /// state there was. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + instanceID = try container.decode(UUID.self, forKey: .instanceID) + displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? true + dateAdded = try container.decode(Date.self, forKey: .dateAdded) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } + /// The account synthesized for an instance that was signed in before /// multi-account support existed. /// diff --git a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift index 0e4f2a1c..cf608de9 100644 --- a/Yattee/Services/CloudKit/CloudKitConflictResolver.swift +++ b/Yattee/Services/CloudKit/CloudKitConflictResolver.swift @@ -401,6 +401,12 @@ actor CloudKitConflictResolver { if localWins { resolved["displayName"] = local["displayName"] + // A local record built before the field existed carries no value; + // clearing the server's would flip the account back to active, so + // absence leaves the server copy standing. + if local["isActive"] != nil { + resolved["isActive"] = local["isActive"] + } resolved["updatedAt"] = local["updatedAt"] } diff --git a/Yattee/Services/CloudKit/CloudKitRecordMapper.swift b/Yattee/Services/CloudKit/CloudKitRecordMapper.swift index e2a5e1d8..064bd440 100644 --- a/Yattee/Services/CloudKit/CloudKitRecordMapper.swift +++ b/Yattee/Services/CloudKit/CloudKitRecordMapper.swift @@ -909,6 +909,7 @@ final class CloudKitRecordMapper { record["accountID"] = account.id.uuidString as CKRecordValue record["instanceID"] = account.instanceID.uuidString as CKRecordValue record["displayName"] = account.displayName as CKRecordValue? + record["isActive"] = (account.isActive ? Int64(1) : Int64(0)) as CKRecordValue record["dateAdded"] = account.dateAdded as CKRecordValue record["updatedAt"] = account.updatedAt as CKRecordValue @@ -961,6 +962,9 @@ final class CloudKitRecordMapper { id: accountID, instanceID: instanceID, displayName: record["displayName"] as? String, + // Absent on records written before the field existed, and every + // such account is active — that was the only state there was. + isActive: ((record["isActive"] as? Int64) ?? 1) == 1, dateAdded: dateAdded, updatedAt: updatedAt ) diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index b03409bf..b706a0bf 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -19,6 +19,10 @@ enum DataContextUnavailability: Hashable, Sendable { /// the user can turn the instance back on — but nothing may be served from /// or written to a server they have disabled. case instanceDisabled + /// The user set the account itself to inactive. Same shape as a disabled + /// instance: the selection is kept, the credential is untouched, and + /// nothing may be served from or written to an account they switched off. + case accountInactive /// No credential on this device yet. On tvOS this never resolves on its own /// — app Keychain items are not synced there — so the UI offers a local /// sign-in rather than implying a wait. @@ -108,12 +112,19 @@ final class DataContextValidator { return .temporarilyUnavailable(.instanceDisabled) } + // Like the disabled source above: not a credential state, and the + // selection is kept so activating the account restores it. + guard account.isActive else { + return .temporarilyUnavailable(.accountInactive) + } + switch accountsManager.status(for: id) { case .signedIn: return .valid case .checking: return .temporarilyUnavailable(.checking) case .credentialMissing: return .temporarilyUnavailable(.credentialMissing) case .credentialRejected: return .temporarilyUnavailable(.credentialRejected) case .instanceDisabled: return .temporarilyUnavailable(.instanceDisabled) + case .inactive: return .temporarilyUnavailable(.accountInactive) case .unreachable: return .temporarilyUnavailable(.unreachable) } } @@ -163,9 +174,15 @@ final class DataContextValidator { var contexts: [DataContext] = [.local] contexts += accountsManager.selectableAccounts.map { DataContext.account($0.id) } + // An inactive account is the exception to "the selection is always + // listed": the user set it aside, and deactivating the selected + // account switches to local anyway — this covers the window where + // another device's deactivation has arrived ahead of its context + // switch. let current = accountsManager.currentContext if case let .account(id) = current, - accountsManager.account(id: id) != nil, + let account = accountsManager.account(id: id), + account.isActive, !contexts.contains(current) { contexts.append(current) } @@ -213,7 +230,7 @@ final class DataContextValidator { return false case let .temporarilyUnavailable(reason): switch reason { - case .instanceDisabled, .credentialRejected, .credentialMissing: + case .instanceDisabled, .accountInactive, .credentialRejected, .credentialMissing: return false case .checking, .instancePending, .unreachable: return true diff --git a/Yattee/Views/Components/AccountStatusBadge.swift b/Yattee/Views/Components/AccountStatusBadge.swift index 7cc5e3e0..39f83576 100644 --- a/Yattee/Views/Components/AccountStatusBadge.swift +++ b/Yattee/Views/Components/AccountStatusBadge.swift @@ -29,6 +29,7 @@ struct AccountStatusBadge: View { case .credentialMissing: String(localized: "accounts.status.credentialMissing") case .credentialRejected: String(localized: "accounts.status.credentialRejected") case .instanceDisabled: String(localized: "accounts.status.instanceDisabled") + case .inactive: String(localized: "accounts.status.inactive") case .unreachable: String(localized: "accounts.status.unreachable") } } @@ -45,6 +46,7 @@ struct AccountStatusBadge: View { case .credentialMissing: .secondary case .credentialRejected: .red case .instanceDisabled: .secondary + case .inactive: .secondary case .unreachable: .orange } } diff --git a/Yattee/Views/Components/DataContextSwitcherMenu.swift b/Yattee/Views/Components/DataContextSwitcherMenu.swift index 92ed1815..99e810b7 100644 --- a/Yattee/Views/Components/DataContextSwitcherMenu.swift +++ b/Yattee/Views/Components/DataContextSwitcherMenu.swift @@ -294,7 +294,7 @@ enum DataContextSwitcherGlyph { return true case let .temporarilyUnavailable(reason): switch reason { - case .instanceDisabled, .credentialRejected, .unreachable: return true + case .instanceDisabled, .accountInactive, .credentialRejected, .unreachable: return true case .checking, .instancePending, .credentialMissing: return false } } diff --git a/Yattee/Views/Components/DataContextUnavailableBanner.swift b/Yattee/Views/Components/DataContextUnavailableBanner.swift index 45f7db36..a35c81ef 100644 --- a/Yattee/Views/Components/DataContextUnavailableBanner.swift +++ b/Yattee/Views/Components/DataContextUnavailableBanner.swift @@ -40,6 +40,7 @@ struct DataContextUnavailableBanner: View { case .checking: "clock" case .instancePending: "clock" case .instanceDisabled: "server.rack" + case .accountInactive: "person.crop.circle.badge.minus" case .credentialMissing: "person.badge.key" case .credentialRejected: "exclamationmark.triangle.fill" case .unreachable: "wifi.exclamationmark" @@ -50,6 +51,7 @@ struct DataContextUnavailableBanner: View { switch reason { case .checking, .instancePending: .secondary case .instanceDisabled: .secondary + case .accountInactive: .secondary case .credentialMissing: .secondary case .credentialRejected: .red case .unreachable: .orange @@ -61,6 +63,7 @@ struct DataContextUnavailableBanner: View { case .checking: String(localized: "subscriptions.banner.checking") case .instancePending: String(localized: "subscriptions.banner.instancePending") case .instanceDisabled: String(localized: "subscriptions.banner.instanceDisabled") + case .accountInactive: String(localized: "subscriptions.banner.accountInactive") case .credentialMissing: String(localized: "subscriptions.banner.credentialMissing") case .credentialRejected: String(localized: "subscriptions.banner.credentialRejected") case .unreachable: String(localized: "subscriptions.banner.unreachable") diff --git a/Yattee/Views/Settings/AccountsListView.swift b/Yattee/Views/Settings/AccountsListView.swift index 25bba578..77020ba0 100644 --- a/Yattee/Views/Settings/AccountsListView.swift +++ b/Yattee/Views/Settings/AccountsListView.swift @@ -359,6 +359,21 @@ struct AccountsListView: View { Label(String(localized: "accounts.rename"), systemImage: "pencil") } + // Not offered on an orphan: with no source there is nothing an active + // account could do that an inactive one cannot. + if instance != nil { + Button { + appEnvironment?.accountsManager.updateIsActive(!account.isActive, for: account.id) + } label: { + Label( + String(localized: account.isActive ? "accounts.deactivate" : "accounts.activate"), + systemImage: account.isActive + ? "person.crop.circle.badge.minus" + : "person.crop.circle.badge.checkmark" + ) + } + } + Button(role: .destructive) { pendingRemoval = PendingAccountRemoval( id: account.id,