mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user