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 + } + } }