From 2e1f411cc87c09cdabc7a8029cb054a4d047e434 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 9 Sep 2026 23:52:54 +0200 Subject: [PATCH] Let the label the account syncs be one the user can set --- Yattee/Localizable.xcstrings | 31 ++++++ .../Subscriptions/DataContextValidator.swift | 29 +++++- .../Views/Settings/AccountRenameAlert.swift | 79 +++++++++++++++ Yattee/Views/Settings/AccountsListView.swift | 37 +++++++ .../Settings/InstanceAccountsSection.swift | 30 ++++++ YatteeTests/AccountsScreenTests.swift | 97 +++++++++++++++++++ 6 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 Yattee/Views/Settings/AccountRenameAlert.swift diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 5a9fb977..8326f85d 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -319,6 +319,37 @@ } } }, + "accounts.rename" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rename" + } + } + } + }, + "accounts.rename.message %@" : { + "comment" : "Rename alert body, naming what an empty field goes back to", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This name is shown on all your devices. Leave it empty to go back to \"%@\"." + } + } + } + }, + "accounts.rename.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rename Account" + } + } + } + }, "accounts.signIn.identityChanged" : { "localizations" : { "en" : { diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index 184740eb..9be93de0 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -204,13 +204,20 @@ final class DataContextValidator { } } - /// User-set label, else the stored login identifier, else the host. - /// - /// The username lives in the Keychain beside the credential rather than in - /// synced metadata, so it is only available on a device that is signed in. + /// User-set label, else whatever the account falls back to. func displayName(for account: Account) -> String { if let name = account.displayName, !name.isEmpty { return name } + return defaultDisplayName(for: account) + } + /// What the account is called with no user-set label: the stored login + /// identifier, else the source's name, else its host. + /// + /// Device-local by nature. The username lives in the Keychain beside the + /// credential rather than in synced metadata, so it is only available on a + /// device that is signed in — two devices holding the very same account can + /// legitimately fall back to different names for it. + func defaultDisplayName(for account: Account) -> String { if let username = appEnvironment? .accountCredentialsManager(for: account)? .username(for: account), @@ -228,4 +235,18 @@ final class DataContextValidator { return String(localized: "accounts.status.instanceMissing") } + + /// What a rename field's contents mean once the user is done typing. + /// + /// `nil` clears the user-set label and hands the account back to + /// `defaultDisplayName`. An empty field says that outright — and so does + /// typing exactly the name the account already falls back to. The field + /// starts prefilled with the label the row is showing, so saving it + /// untouched must not turn a *device-local* fallback into a synced label + /// that every other device is then made to display. + static func renamedDisplayName(_ input: String, fallingBackTo defaultName: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != defaultName else { return nil } + return trimmed + } } diff --git a/Yattee/Views/Settings/AccountRenameAlert.swift b/Yattee/Views/Settings/AccountRenameAlert.swift new file mode 100644 index 00000000..6aaf6039 --- /dev/null +++ b/Yattee/Views/Settings/AccountRenameAlert.swift @@ -0,0 +1,79 @@ +// +// AccountRenameAlert.swift +// Yattee +// +// The one place an account's synced label is set. +// + +import SwiftUI + +/// An account the user asked to rename, and what its field starts on. +/// +/// Carries the strings rather than the account so the surfaces that name an +/// account differently — an orphan resolves through no source — can each say +/// what the field should offer, while the alert itself stays the same +/// everywhere. +struct AccountRenameRequest: Identifiable { + let id: UUID + + /// The label the account shows with no user-set name. Doubles as the + /// placeholder and as the value that means "no custom name at all". + let defaultName: String + + /// What the field is prefilled with: the label the row is showing now. + let initialText: String +} + +/// The rename alert, shared by every screen that lists accounts. +/// +/// A text field in an alert rather than a sheet: the whole edit is one short +/// string, and it is the app's existing way of asking for one on tvOS, where +/// there is no sheet-sized form for a single field. +private struct AccountRenameAlertModifier: ViewModifier { + @Binding var request: AccountRenameRequest? + let onCommit: (UUID, String?) -> Void + + @State private var text = "" + + func body(content: Content) -> some View { + content + // Keyed on the ID rather than on the request: the prefill has to be + // in place before the alert's field is built, and re-entering the + // same row is the only time the text should be reset. + .onChange(of: request?.id) { _, _ in + text = request?.initialText ?? "" + } + .alert( + String(localized: "accounts.rename.title"), + isPresented: Binding( + get: { request != nil }, + set: { if !$0 { request = nil } } + ), + presenting: request + ) { pending in + TextField(pending.defaultName, text: $text) + + Button(String(localized: "common.save")) { + onCommit( + pending.id, + DataContextValidator.renamedDisplayName(text, fallingBackTo: pending.defaultName) + ) + } + + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { pending in + Text(String(localized: "accounts.rename.message \(pending.defaultName)")) + } + } +} + +extension View { + /// Presents the rename alert for `request`, calling `onCommit` with the + /// label to store — `nil` when the user cleared it. + func accountRenameAlert( + _ request: Binding, + onCommit: @escaping (UUID, String?) -> Void + ) -> some View { + modifier(AccountRenameAlertModifier(request: request, onCommit: onCommit)) + } +} diff --git a/Yattee/Views/Settings/AccountsListView.swift b/Yattee/Views/Settings/AccountsListView.swift index 4e992125..66f20af9 100644 --- a/Yattee/Views/Settings/AccountsListView.swift +++ b/Yattee/Views/Settings/AccountsListView.swift @@ -36,6 +36,7 @@ struct AccountsListView: View { @State private var login: AccountsListLogin? @State private var pendingRemoval: PendingAccountRemoval? + @State private var renameRequest: AccountRenameRequest? /// Bumped whenever a credential is replaced, purely to re-read the login /// identifier the rows display. @@ -107,6 +108,9 @@ struct AccountsListView: View { #if !os(tvOS) .presentationCompactAdaptation(.sheet) #endif + .accountRenameAlert($renameRequest) { accountID, name in + appEnvironment?.accountsManager.updateDisplayName(name, for: accountID) + } .onReceive(NotificationCenter.default.publisher(for: .accountCredentialDidChange)) { _ in credentialRevision &+= 1 } @@ -276,6 +280,17 @@ struct AccountsListView: View { #endif } + // Offered on orphans too: with no source to fall back to, a label of + // its own is the only thing that tells one abandoned login from another. + Button { + renameRequest = makeRenameRequest(for: account, instance: instance) + } label: { + Label(String(localized: "accounts.rename"), systemImage: "pencil") + } + #if os(tvOS) + .buttonStyle(TVSettingsButtonStyle()) + #endif + Button(role: .destructive) { pendingRemoval = PendingAccountRemoval( id: account.id, @@ -326,6 +341,28 @@ struct AccountsListView: View { ?? String(localized: "accounts.orphan.unnamed") } + /// What the rename field should start on, and what clearing it goes back to. + /// + /// An orphan is the exception for the same reason `title(for:instance:)` + /// is: with no source to resolve through, the validator's last resort is + /// the words "Source missing", which is a diagnosis and not a name to + /// prefill a field with. + private func makeRenameRequest(for account: Account, instance: Instance?) -> AccountRenameRequest { + guard instance != nil, let validator = appEnvironment?.dataContextValidator else { + return AccountRenameRequest( + id: account.id, + defaultName: String(localized: "accounts.orphan.unnamed"), + initialText: account.displayName ?? "" + ) + } + + return AccountRenameRequest( + id: account.id, + defaultName: validator.defaultDisplayName(for: account), + initialText: validator.displayName(for: account) + ) + } + private func status(for account: Account) -> AccountStatus { accountsManager?.status(for: account.id) ?? .checking } diff --git a/Yattee/Views/Settings/InstanceAccountsSection.swift b/Yattee/Views/Settings/InstanceAccountsSection.swift index 5a0ce72c..c7c0ff9b 100644 --- a/Yattee/Views/Settings/InstanceAccountsSection.swift +++ b/Yattee/Views/Settings/InstanceAccountsSection.swift @@ -45,6 +45,10 @@ struct InstanceAccountsSection: View { /// sharing the Keychain, which is not something to do on one tap. @State private var pendingSignOut: Account? + /// The account a rename was asked for. Like the sign-out above it, held on + /// the section rather than the row, and matched back to a row by ID. + @State private var renameRequest: AccountRenameRequest? + private var accounts: [Account] { appEnvironment?.accountsManager.accounts(forInstance: instance.id) ?? [] } @@ -120,6 +124,12 @@ struct InstanceAccountsSection: View { Label(String(localized: "login.signInAgain"), systemImage: "person.badge.key") } + Button { + renameRequest = makeRenameRequest(for: account) + } label: { + Label(String(localized: "accounts.rename"), systemImage: "pencil") + } + Button(role: .destructive) { pendingSignOut = account } label: { @@ -151,6 +161,16 @@ struct InstanceAccountsSection: View { #if !os(tvOS) .presentationCompactAdaptation(.sheet) #endif + // Attached to the row for the same reason the dialog above it is, and + // narrowed to the row that raised it so only one alert is ever live. + .accountRenameAlert( + Binding( + get: { renameRequest?.id == account.id ? renameRequest : nil }, + set: { if $0 == nil { renameRequest = nil } } + ) + ) { accountID, name in + appEnvironment?.accountsManager.updateDisplayName(name, for: accountID) + } #if os(tvOS) // tvOS never syncs app Keychain items, so an account that arrived from @@ -174,6 +194,16 @@ struct InstanceAccountsSection: View { appEnvironment?.dataContextValidator.displayName(for: account) ?? instance.displayName } + /// What the rename field starts on, and what clearing it goes back to. + private func makeRenameRequest(for account: Account) -> AccountRenameRequest { + AccountRenameRequest( + id: account.id, + defaultName: appEnvironment?.dataContextValidator.defaultDisplayName(for: account) + ?? instance.displayName, + initialText: displayName(for: account) + ) + } + private func status(for account: Account) -> AccountStatus { appEnvironment?.accountsManager.status(for: account.id) ?? .checking } diff --git a/YatteeTests/AccountsScreenTests.swift b/YatteeTests/AccountsScreenTests.swift index ce255e38..f4a19401 100644 --- a/YatteeTests/AccountsScreenTests.swift +++ b/YatteeTests/AccountsScreenTests.swift @@ -458,3 +458,100 @@ struct DataContextSwitcherLabelTests { ) } } + +// MARK: - Rename + +@Suite("Account rename") +@MainActor +struct AccountRenameTests { + private func makeDefaults() -> UserDefaults { + UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")! + } + + private func makeScope() -> CredentialIdentityScope { + CredentialIdentityScope(fixedScope: "identity-\(UUID().uuidString)") + } + + private func makeFixture() -> (AccountsManager, InstancesManager, DataContextValidator) { + let instances = makeTestInstancesManager() + let accounts = AccountsManager( + identityScope: makeScope(), + instancesManager: instances, + defaults: makeDefaults() + ) + let validator = DataContextValidator( + accountsManager: accounts, + instancesManager: instances, + toastManager: ToastManager() + ) + return (accounts, instances, validator) + } + + @Test("A typed name replaces the fallback") + func typedNameIsStored() { + #expect( + DataContextValidator.renamedDisplayName(" Main i01 ", fallingBackTo: "i01") == "Main i01" + ) + } + + @Test("An empty field clears the label") + func emptyClearsTheLabel() { + #expect(DataContextValidator.renamedDisplayName("", fallingBackTo: "i01") == nil) + #expect(DataContextValidator.renamedDisplayName(" \n ", fallingBackTo: "i01") == nil) + } + + @Test("Saving the prefilled fallback untouched sets no name at all") + func unchangedFallbackIsNotStored() { + // The field starts on the label the row is showing, and that label can + // be device-local — the login identifier only exists where the + // credential does. Storing it would publish one device's fallback to + // every other device as if the user had chosen it. + #expect(DataContextValidator.renamedDisplayName("i01", fallingBackTo: "i01") == nil) + #expect( + DataContextValidator.renamedDisplayName( + "utest1@i01.v.yattee.stream", + fallingBackTo: "utest1@i01.v.yattee.stream" + ) == nil + ) + } + + @Test("The default name ignores the user-set one, and the shown name prefers it") + func defaultIgnoresTheUserSetLabel() { + let (accounts, instances, validator) = makeFixture() + var instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!) + instance.name = "My Invidious" + instances.add(instance) + + let account = accounts.addAccount(instanceID: instance.id) + + #expect(validator.defaultDisplayName(for: account) == "My Invidious") + #expect(validator.displayName(for: account) == "My Invidious") + + accounts.updateDisplayName("Main i01", for: account.id) + let renamed = accounts.account(id: account.id)! + + #expect(validator.displayName(for: renamed) == "Main i01") + // Unchanged: clearing the name has somewhere to go back to. + #expect(validator.defaultDisplayName(for: renamed) == "My Invidious") + } + + @Test("A rename is queued for upload, and clearing it is queued too") + func renameIsQueuedForUpload() { + let (accounts, instances, _) = makeFixture() + let instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!) + instances.add(instance) + + let account = accounts.addAccount(instanceID: instance.id) + accounts.confirmUpload(account.id) + #expect(!accounts.pendingUploads.contains(account.id)) + + accounts.updateDisplayName("Main i01", for: account.id) + #expect(accounts.pendingUploads.contains(account.id)) + #expect(accounts.account(id: account.id)?.displayName == "Main i01") + + accounts.confirmUpload(account.id) + accounts.updateDisplayName(nil, for: account.id) + #expect(accounts.pendingUploads.contains(account.id)) + #expect(accounts.account(id: account.id)?.displayName == nil) + } +}