From 49457f7ae89282a85908b943181dcbc9dac19e9a Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:04:16 +0200 Subject: [PATCH] Multi account feature flag (#6261) * Add a multi-account feature flag The flag lives in Developer options. Switching it on arms the upcoming one-time multi-account announcement, whose seen state defaults to true so fresh installs never see it. * Add an Add account row to Settings behind the multi-account flag The row sits in its own section under the user status, as in the designs. Tapping it is routed up to the settings flow, which ignores it until the add account flow exists. * Remove the Developer options view model tests The Developer options screen is excluded from coverage, so these tests don't add value. * Apply review suggestions to the multi-account flag - Order the multi-account requirements in DeveloperOptionsProtocol to match the screen's sections. - Drop the redundant "Experimental" from the toggle's subtitle. --- .../Application/Settings/AppSettings.swift | 7 ++++ .../SettingsFlowCoordinator.swift | 2 + .../Other/AccessibilityIdentifiers.swift | 1 + .../DeveloperOptionsScreenModels.swift | 5 +++ .../DeveloperOptionsScreenViewModel.swift | 7 ++++ .../View/DeveloperOptionsScreen.swift | 7 ++++ .../SettingsScreenCoordinator.swift | 3 ++ .../SettingsScreen/SettingsScreenModels.swift | 3 ++ .../SettingsScreenViewModel.swift | 7 ++++ .../SettingsScreen/View/SettingsScreen.swift | 23 ++++++++++-- ...sScreen.Bug-report-disabled-iPad-en-GB.png | 4 +- ...Screen.Bug-report-disabled-iPad-pseudo.png | 4 +- ...creen.Bug-report-disabled-iPhone-en-GB.png | 4 +- ...reen.Bug-report-disabled-iPhone-pseudo.png | 4 +- .../settingsScreen.Default-iPad-en-GB.png | 4 +- .../settingsScreen.Default-iPad-pseudo.png | 4 +- .../settingsScreen.Default-iPhone-en-GB.png | 4 +- .../settingsScreen.Default-iPhone-pseudo.png | 4 +- .../SettingsScreenViewModelTests.swift | 37 ++++++++++++++++++- 19 files changed, 113 insertions(+), 21 deletions(-) diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 2de8f94cd..f97d0ec98 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -139,6 +139,10 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: true) var hasSeenNewSoundBanner: Bool + /// Defaults to `true` so new users never see it. Developer options arms it for now, an upgrade migration will at launch. + @UserPreference(defaultValue: true) + var hasSeenMultiAccountAnnouncement: Bool + /// The initial set of account providers shown to the user in the authentication flow. /// /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should @@ -471,6 +475,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var nativeCallEnabled: Bool + @UserPreference(defaultValue: false) + var multiAccountEnabled: Bool + init(store: UserDefaultsProtocol) { self.store = store } diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index 6b7c63a98..2594fe505 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -93,6 +93,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { presentUserDetailsEditScreen() case let .userStatusEmojiPicker(continuation): presentEmojiPicker(emojiPickerContinuation: continuation) + case .addAccount: + break // Nothing to present until the add account flow exists. case .linkNewDevice: startLinkNewDeviceFlow() case let .manageAccount(url): diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index 50450d3bc..30e536e3e 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -254,6 +254,7 @@ enum A11yIdentifiers { struct SettingsScreen { let done = "settings-done" let account = "settings-account" + let addAccount = "settings-add_account" let secureBackup = "settings-secure_backup" let notifications = "settings-notifications" let analytics = "settings-analytics" diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 8c93bfd4a..a01da63e7 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine import Foundation enum DeveloperOptionsScreenViewModelAction { @@ -72,6 +73,10 @@ protocol DeveloperOptionsProtocol: AnyObject { var globalSearchEnabled: Bool { get set } + var multiAccountEnabled: Bool { get set } + var multiAccountEnabledPublisher: AnyPublisher { get } + var hasSeenMultiAccountAnnouncement: Bool { get set } + var nativeCallEnabled: Bool { get set } } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index 187efd11a..cacba6807 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -27,6 +27,13 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve isSignedIn: clientProxy != nil, bindings: .init(developerOptions: developerOptions))) + developerOptions.multiAccountEnabledPublisher + .removeDuplicates() + .dropFirst() // The publisher replays the current value, so only an actual toggle arms the announcement. + .filter { $0 } + .sink { _ in developerOptions.hasSeenMultiAccountAnnouncement = false } + .store(in: &cancellables) + Task { if case let .success(sizes) = await clientProxy?.storeSizes() { let formatter = ByteCountFormatStyle(style: .file) diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 653c1c4c1..0e0ce9d85 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -59,6 +59,13 @@ struct DeveloperOptionsScreen: View { .generalSectionRows(isSignedIn: context.viewState.isSignedIn) } + Section("Accounts") { + Toggle(isOn: $context.multiAccountEnabled) { + Text("Multi-account") + Text("Switching it on shows the announcement again.") + } + } + Section("Room List") { Toggle(isOn: $context.fuzzyRoomListSearchEnabled) { Text("Fuzzy searching") diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index b21423ff9..ef2a7d189 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -23,6 +23,7 @@ enum SettingsScreenCoordinatorAction { case secureBackup case userDetails case userStatusEmojiPicker(EmojiPickerScreenContinuation) + case addAccount case analytics case appLock case bugReport @@ -67,6 +68,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.userDetails) case let .userStatusEmojiPicker(continuation): actionsSubject.send(.userStatusEmojiPicker(continuation)) + case .addAccount: + actionsSubject.send(.addAccount) case .linkNewDevice: actionsSubject.send(.linkNewDevice) case let .manageAccount(url): diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift index 511aaf000..74085bdf6 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenModels.swift @@ -12,6 +12,7 @@ enum SettingsScreenViewModelAction { case close case userDetails case userStatusEmojiPicker(EmojiPickerScreenContinuation) + case addAccount case linkNewDevice case manageAccount(url: URL) case analytics @@ -38,6 +39,7 @@ struct SettingsScreenViewState: BindableState { var userProfile: UserProfile var showUserStatusInput = false var showLinkNewDeviceButton: Bool + var showAddAccountButton: Bool var accountProfileURL: URL? var showAccountDeactivation: Bool var showDeveloperOptions: Bool @@ -83,6 +85,7 @@ enum SettingsScreenViewAction { case close case userDetails case userStatus(UserStatusAction) + case addAccount case analytics case appLock case reportBug diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift index c0c4d4b12..9634e4bee 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenViewModel.swift @@ -30,6 +30,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID, userProfile: userSession.clientProxy.userProfilePublisher.value, showLinkNewDeviceButton: userSettings.linkNewDeviceEnabled, + showAddAccountButton: userSettings.multiAccountEnabled, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, showDeveloperOptions: userSettings.developerOptionsEnabled, showAnalyticsSettings: userSettings.canPromptForAnalytics, @@ -45,6 +46,10 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo .weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .store(in: &cancellables) + userSettings.multiAccountEnabledPublisher + .weakAssign(to: \.state.showAddAccountButton, on: self) + .store(in: &cancellables) + userSession.clientProxy.userProfilePublisher .receive(on: DispatchQueue.main) .weakAssign(to: \.state.userProfile, on: self) @@ -113,6 +118,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo case .userStatus(.cancel): state.bindings.isPresentingStatusPicker = false state.bindings.isShowingCustomStatusField = false + case .addAccount: + actionsSubject.send(.addAccount) case .linkNewDevice: actionsSubject.send(.linkNewDevice) case let .manageAccount(url): diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift index a1f03b3d0..b3fed9794 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/View/SettingsScreen.swift @@ -27,6 +27,10 @@ struct SettingsScreen: View { userStatusSection } + if context.viewState.showAddAccountButton { + addAccountSection + } + if !shouldHideManageAccountSection { manageAccountSection } @@ -98,6 +102,17 @@ struct SettingsScreen: View { } } + private var addAccountSection: some View { + Section { + ListRow(label: .default(title: L10n.screenSettingsAddAccount, + icon: \.plus), + kind: .navigationLink { + context.send(viewAction: .addAccount) + }) + .accessibilityIdentifier(A11yIdentifiers.settingsScreen.addAccount) + } + } + private var manageMyAppSection: some View { Section { ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, @@ -288,7 +303,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { SettingsScreen(context: viewModel.context) } .snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) - .frame(height: 1100) + .frame(height: 1200) .previewLayout(.sizeThatFits) .previewDisplayName("Default") @@ -296,7 +311,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { SettingsScreen(context: bugReportDisabledViewModel.context) } .snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) - .frame(height: 1050) + .frame(height: 1150) .previewLayout(.sizeThatFits) .previewDisplayName("Bug report disabled") } @@ -306,8 +321,10 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview { deviceID: "AAAAAAAAAAA", displayName: "Alice Liddell", status: .mockFocussing)))) + let userSettings = UserSettings.volatile() + userSettings.multiAccountEnabled = true return SettingsScreenViewModel(userSession: userSession, - userSettings: .volatile(), + userSettings: userSettings, isBugReportServiceEnabled: isBugReportServiceEnabled, isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png index 357e78b50..a15146503 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5b862b4039fe3d4cfdb10e98cd208f7502b8563d8b74ddf5a8de5d62bbcd66d2 -size 192952 +oid sha256:9eeed90de894bbf66e72b23a9cfe8b8aa455e8a9ff3f509a227203b2734bd411 +size 208003 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png index d7162c0f5..256f2a2f0 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9b1af97b7b27bf112b82055645b1dcb346db02b0ac8025c1bf5d128837fe622 -size 203581 +oid sha256:daa1d62daa786fa52e35f9f9924b969a627649037cf64e6949b692de837f3f89 +size 218666 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png index 329181821..53e7066ec 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a057099df1dc12a7fbcfccc11326447217feefe07a0ac635e3c26ef5eb1b154d -size 124227 +oid sha256:02ddd70542f4e8963f1745ee9f65e6f42e864bad5a28dffe052de35ac2ead3aa +size 132702 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png index 7aab1390a..c12942184 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Bug-report-disabled-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ee44d9ff3620ebfd247c21e9c7b4770cb92de8a8e4ccc5a0d4c912a334ba74c -size 139069 +oid sha256:ef1a98de9b2ddfb490882c5a937d2d7159a0a0ab513f41a8e1a1b1b3c95d5323 +size 148131 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png index 7feaee11e..e3bbe45a8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f0e31993f675ad48f19a22124114238910b1985b52d17dd47874f4b64b35b727 -size 203814 +oid sha256:56417caadb44c989b9ca9032d8eb6b13e5d02cead1537eb04a2ea262c8a3ea00 +size 218308 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png index b02dd93cf..261e22ee4 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5a0ca5db21d954937ccae99541c396cdaa0efd2b7b7111ff1d95e2481db0bc42 -size 215504 +oid sha256:450405a9646962d1889af363b6da096a651d3d40dcb9a0212cafb7c1a2695f7d +size 230377 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png index e3c402793..c20a39d8a 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:34c1c30fb77417e9318dca1d6011606511d7d7fdc12288f8746060aeb754bc76 -size 131742 +oid sha256:a3dcc827b87f5b49344477648239fa7bb46c1101324fab00ac8690943369fcf6 +size 140339 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png index ad03b33d2..db90fff62 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/settingsScreen.Default-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6d7547adae45069046fd76f57e0897b0af8e7b319e4f515aa2869d3a0b1d5ba -size 149600 +oid sha256:91c5820e827e06ff4c2ab55c5ff86425fe0634172e3cfc2134574a6c955fb89e +size 158619 diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index e899a9bcb..5344f309b 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -172,12 +172,38 @@ struct SettingsScreenViewModelTests { try await deferred.fulfill() } + // MARK: - Add account + + @Test + mutating func addAccountButtonFollowsTheFeatureFlag() async throws { + let userSettings = UserSettings.volatile() + setupViewModel(userSettings: userSettings) + #expect(!context.viewState.showAddAccountButton) + + var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 } + userSettings.multiAccountEnabled = true + try await deferred.fulfill() + + deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 } + userSettings.multiAccountEnabled = false + try await deferred.fulfill() + } + + @Test + mutating func addAccount() async throws { + setupViewModel() + + let deferred = deferFulfillment(viewModel.actions) { $0.isAddAccount } + context.send(viewAction: .addAccount) + try await deferred.fulfill() + } + // MARK: - Helpers - private mutating func setupViewModel(status: UserStatus = .init()) { + private mutating func setupViewModel(status: UserStatus = .init(), userSettings: UserSettings = .volatile()) { clientProxy = ClientProxyMock(.init(userID: "", status: status)) viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), - userSettings: UserSettings.volatile(), + userSettings: userSettings, isBugReportServiceEnabled: true, isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) @@ -226,4 +252,11 @@ private extension SettingsScreenViewModelAction { default: false } } + + var isAddAccount: Bool { + switch self { + case .addAccount: true + default: false + } + } }