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.
This commit is contained in:
Mauro
2026-10-05 13:04:16 +02:00
committed by GitHub
parent 9ce3bd97d8
commit 49457f7ae8
19 changed files with 113 additions and 21 deletions
@@ -139,6 +139,10 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: true) @UserPreference(defaultValue: true)
var hasSeenNewSoundBanner: Bool 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. /// 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 /// 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) @UserPreference(defaultValue: false)
var nativeCallEnabled: Bool var nativeCallEnabled: Bool
@UserPreference(defaultValue: false)
var multiAccountEnabled: Bool
init(store: UserDefaultsProtocol) { init(store: UserDefaultsProtocol) {
self.store = store self.store = store
} }
@@ -93,6 +93,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
presentUserDetailsEditScreen() presentUserDetailsEditScreen()
case let .userStatusEmojiPicker(continuation): case let .userStatusEmojiPicker(continuation):
presentEmojiPicker(emojiPickerContinuation: continuation) presentEmojiPicker(emojiPickerContinuation: continuation)
case .addAccount:
break // Nothing to present until the add account flow exists.
case .linkNewDevice: case .linkNewDevice:
startLinkNewDeviceFlow() startLinkNewDeviceFlow()
case let .manageAccount(url): case let .manageAccount(url):
@@ -254,6 +254,7 @@ enum A11yIdentifiers {
struct SettingsScreen { struct SettingsScreen {
let done = "settings-done" let done = "settings-done"
let account = "settings-account" let account = "settings-account"
let addAccount = "settings-add_account"
let secureBackup = "settings-secure_backup" let secureBackup = "settings-secure_backup"
let notifications = "settings-notifications" let notifications = "settings-notifications"
let analytics = "settings-analytics" let analytics = "settings-analytics"
@@ -6,6 +6,7 @@
// Please see LICENSE files in the repository root for full details. // Please see LICENSE files in the repository root for full details.
// //
import Combine
import Foundation import Foundation
enum DeveloperOptionsScreenViewModelAction { enum DeveloperOptionsScreenViewModelAction {
@@ -72,6 +73,10 @@ protocol DeveloperOptionsProtocol: AnyObject {
var globalSearchEnabled: Bool { get set } var globalSearchEnabled: Bool { get set }
var multiAccountEnabled: Bool { get set }
var multiAccountEnabledPublisher: AnyPublisher<Bool, Never> { get }
var hasSeenMultiAccountAnnouncement: Bool { get set }
var nativeCallEnabled: Bool { get set } var nativeCallEnabled: Bool { get set }
} }
@@ -27,6 +27,13 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
isSignedIn: clientProxy != nil, isSignedIn: clientProxy != nil,
bindings: .init(developerOptions: developerOptions))) 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 { Task {
if case let .success(sizes) = await clientProxy?.storeSizes() { if case let .success(sizes) = await clientProxy?.storeSizes() {
let formatter = ByteCountFormatStyle(style: .file) let formatter = ByteCountFormatStyle(style: .file)
@@ -59,6 +59,13 @@ struct DeveloperOptionsScreen: View {
.generalSectionRows(isSignedIn: context.viewState.isSignedIn) .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") { Section("Room List") {
Toggle(isOn: $context.fuzzyRoomListSearchEnabled) { Toggle(isOn: $context.fuzzyRoomListSearchEnabled) {
Text("Fuzzy searching") Text("Fuzzy searching")
@@ -23,6 +23,7 @@ enum SettingsScreenCoordinatorAction {
case secureBackup case secureBackup
case userDetails case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation) case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case addAccount
case analytics case analytics
case appLock case appLock
case bugReport case bugReport
@@ -67,6 +68,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.userDetails) actionsSubject.send(.userDetails)
case let .userStatusEmojiPicker(continuation): case let .userStatusEmojiPicker(continuation):
actionsSubject.send(.userStatusEmojiPicker(continuation)) actionsSubject.send(.userStatusEmojiPicker(continuation))
case .addAccount:
actionsSubject.send(.addAccount)
case .linkNewDevice: case .linkNewDevice:
actionsSubject.send(.linkNewDevice) actionsSubject.send(.linkNewDevice)
case let .manageAccount(url): case let .manageAccount(url):
@@ -12,6 +12,7 @@ enum SettingsScreenViewModelAction {
case close case close
case userDetails case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation) case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case addAccount
case linkNewDevice case linkNewDevice
case manageAccount(url: URL) case manageAccount(url: URL)
case analytics case analytics
@@ -38,6 +39,7 @@ struct SettingsScreenViewState: BindableState {
var userProfile: UserProfile var userProfile: UserProfile
var showUserStatusInput = false var showUserStatusInput = false
var showLinkNewDeviceButton: Bool var showLinkNewDeviceButton: Bool
var showAddAccountButton: Bool
var accountProfileURL: URL? var accountProfileURL: URL?
var showAccountDeactivation: Bool var showAccountDeactivation: Bool
var showDeveloperOptions: Bool var showDeveloperOptions: Bool
@@ -83,6 +85,7 @@ enum SettingsScreenViewAction {
case close case close
case userDetails case userDetails
case userStatus(UserStatusAction) case userStatus(UserStatusAction)
case addAccount
case analytics case analytics
case appLock case appLock
case reportBug case reportBug
@@ -30,6 +30,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID, super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID,
userProfile: userSession.clientProxy.userProfilePublisher.value, userProfile: userSession.clientProxy.userProfilePublisher.value,
showLinkNewDeviceButton: userSettings.linkNewDeviceEnabled, showLinkNewDeviceButton: userSettings.linkNewDeviceEnabled,
showAddAccountButton: userSettings.multiAccountEnabled,
showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount,
showDeveloperOptions: userSettings.developerOptionsEnabled, showDeveloperOptions: userSettings.developerOptionsEnabled,
showAnalyticsSettings: userSettings.canPromptForAnalytics, showAnalyticsSettings: userSettings.canPromptForAnalytics,
@@ -45,6 +46,10 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
.weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .weakAssign(to: \.state.showLinkNewDeviceButton, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.multiAccountEnabledPublisher
.weakAssign(to: \.state.showAddAccountButton, on: self)
.store(in: &cancellables)
userSession.clientProxy.userProfilePublisher userSession.clientProxy.userProfilePublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userProfile, on: self) .weakAssign(to: \.state.userProfile, on: self)
@@ -113,6 +118,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
case .userStatus(.cancel): case .userStatus(.cancel):
state.bindings.isPresentingStatusPicker = false state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false state.bindings.isShowingCustomStatusField = false
case .addAccount:
actionsSubject.send(.addAccount)
case .linkNewDevice: case .linkNewDevice:
actionsSubject.send(.linkNewDevice) actionsSubject.send(.linkNewDevice)
case let .manageAccount(url): case let .manageAccount(url):
@@ -27,6 +27,10 @@ struct SettingsScreen: View {
userStatusSection userStatusSection
} }
if context.viewState.showAddAccountButton {
addAccountSection
}
if !shouldHideManageAccountSection { if !shouldHideManageAccountSection {
manageAccountSection 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 { private var manageMyAppSection: some View {
Section { Section {
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
@@ -288,7 +303,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: viewModel.context) SettingsScreen(context: viewModel.context)
} }
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) .snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1100) .frame(height: 1200)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.previewDisplayName("Default") .previewDisplayName("Default")
@@ -296,7 +311,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: bugReportDisabledViewModel.context) SettingsScreen(context: bugReportDisabledViewModel.context)
} }
.snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) .snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1050) .frame(height: 1150)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.previewDisplayName("Bug report disabled") .previewDisplayName("Bug report disabled")
} }
@@ -306,8 +321,10 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
deviceID: "AAAAAAAAAAA", deviceID: "AAAAAAAAAAA",
displayName: "Alice Liddell", displayName: "Alice Liddell",
status: .mockFocussing)))) status: .mockFocussing))))
let userSettings = UserSettings.volatile()
userSettings.multiAccountEnabled = true
return SettingsScreenViewModel(userSession: userSession, return SettingsScreenViewModel(userSession: userSession,
userSettings: .volatile(), userSettings: userSettings,
isBugReportServiceEnabled: isBugReportServiceEnabled, isBugReportServiceEnabled: isBugReportServiceEnabled,
isInSecondaryWindow: false, isInSecondaryWindow: false,
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5b862b4039fe3d4cfdb10e98cd208f7502b8563d8b74ddf5a8de5d62bbcd66d2 oid sha256:9eeed90de894bbf66e72b23a9cfe8b8aa455e8a9ff3f509a227203b2734bd411
size 192952 size 208003
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a9b1af97b7b27bf112b82055645b1dcb346db02b0ac8025c1bf5d128837fe622 oid sha256:daa1d62daa786fa52e35f9f9924b969a627649037cf64e6949b692de837f3f89
size 203581 size 218666
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a057099df1dc12a7fbcfccc11326447217feefe07a0ac635e3c26ef5eb1b154d oid sha256:02ddd70542f4e8963f1745ee9f65e6f42e864bad5a28dffe052de35ac2ead3aa
size 124227 size 132702
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0ee44d9ff3620ebfd247c21e9c7b4770cb92de8a8e4ccc5a0d4c912a334ba74c oid sha256:ef1a98de9b2ddfb490882c5a937d2d7159a0a0ab513f41a8e1a1b1b3c95d5323
size 139069 size 148131
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f0e31993f675ad48f19a22124114238910b1985b52d17dd47874f4b64b35b727 oid sha256:56417caadb44c989b9ca9032d8eb6b13e5d02cead1537eb04a2ea262c8a3ea00
size 203814 size 218308
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5a0ca5db21d954937ccae99541c396cdaa0efd2b7b7111ff1d95e2481db0bc42 oid sha256:450405a9646962d1889af363b6da096a651d3d40dcb9a0212cafb7c1a2695f7d
size 215504 size 230377
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:34c1c30fb77417e9318dca1d6011606511d7d7fdc12288f8746060aeb754bc76 oid sha256:a3dcc827b87f5b49344477648239fa7bb46c1101324fab00ac8690943369fcf6
size 131742 size 140339
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d6d7547adae45069046fd76f57e0897b0af8e7b319e4f515aa2869d3a0b1d5ba oid sha256:91c5820e827e06ff4c2ab55c5ff86425fe0634172e3cfc2134574a6c955fb89e
size 149600 size 158619
@@ -172,12 +172,38 @@ struct SettingsScreenViewModelTests {
try await deferred.fulfill() 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 // 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)) clientProxy = ClientProxyMock(.init(userID: "", status: status))
viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
userSettings: UserSettings.volatile(), userSettings: userSettings,
isBugReportServiceEnabled: true, isBugReportServiceEnabled: true,
isInSecondaryWindow: false, isInSecondaryWindow: false,
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
@@ -226,4 +252,11 @@ private extension SettingsScreenViewModelAction {
default: false default: false
} }
} }
var isAddAccount: Bool {
switch self {
case .addAccount: true
default: false
}
}
} }