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)
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
}
@@ -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):
@@ -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"
@@ -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<Bool, Never> { get }
var hasSeenMultiAccountAnnouncement: Bool { get set }
var nativeCallEnabled: Bool { get set }
}
@@ -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)
@@ -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")
@@ -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):
@@ -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
@@ -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):
@@ -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())