mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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"
|
||||||
|
|||||||
+5
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
@@ -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)
|
||||||
|
|||||||
+7
@@ -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())
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user