Remove the dynamic member lookup from UserSettings in favour of nested access. (#6294)

* Remove the dynamic member lookup from UserSettings in favour of nested access.

* Remove forwarding methods from UserSettings.

* Update the Enterprise submodule.
This commit is contained in:
Doug
2026-10-08 15:19:44 +01:00
committed by GitHub
parent fba28ed422
commit fc75f90ef5
75 changed files with 346 additions and 374 deletions
@@ -495,8 +495,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if oldVersion < Version(1, 6, 0) { if oldVersion < Version(1, 6, 0) {
MXLog.info("Migrating to v1.6.0, marking identity confirmation onboarding as ran.") MXLog.info("Migrating to v1.6.0, marking identity confirmation onboarding as ran.")
userSession.userSettings.hasRunIdentityConfirmationOnboarding = true userSession.userSettings.account.hasRunIdentityConfirmationOnboarding = true
userSession.userSettings.hasRunNotificationPermissionsOnboarding = true userSession.userSettings.app.hasRunNotificationPermissionsOnboarding = true
} }
if oldVersion < Version(25, 6, 0) { if oldVersion < Version(25, 6, 0) {
@@ -521,8 +521,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if oldVersion < Version(26, 10, 1) { if oldVersion < Version(26, 10, 1) {
MXLog.info("Migrating to version 26.10.1, moving initial 'session specific' settings.") MXLog.info("Migrating to version 26.10.1, moving initial 'session specific' settings.")
userSession.userSettings.migrateAppSettingsValueToAccountSettings(\.hasRunIdentityConfirmationOnboardingKey) userSession.userSettings.account.migrateAppSettingsValue(\.hasRunIdentityConfirmationOnboardingKey)
userSession.userSettings.migrateAppSettingsValueToAccountSettings(\.searchBreadcrumbsKey) userSession.userSettings.account.migrateAppSettingsValue(\.searchBreadcrumbsKey)
} }
userSessionMigrationsOldVersion = nil userSessionMigrationsOldVersion = nil
@@ -872,7 +872,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSessionStore.logout(userSession: userSession) userSessionStore.logout(userSession: userSession)
tearDownUserSession() tearDownUserSession()
userSession.userSettings.resetSessionSpecificSettings() userSession.userSettings.account.reset()
appHooks.remoteSettingsHook.reset(appSettings) appHooks.remoteSettingsHook.reset(appSettings)
// Reset analytics // Reset analytics
@@ -1385,7 +1385,7 @@ private extension AppCoordinator {
} }
func scheduleSearchBackfill() { func scheduleSearchBackfill() {
guard let userSession, userSession.userSettings.globalSearchEnabled, #available(iOS 26.0, *) else { guard let userSession, userSession.userSettings.app.globalSearchEnabled, #available(iOS 26.0, *) else {
return return
} }
@@ -10,44 +10,16 @@ import Macros
/// The settings for a signed in account, composed of the app wide settings and the settings /// The settings for a signed in account, composed of the app wide settings and the settings
/// specific to that account. /// specific to that account.
@dynamicMemberLookup
final nonisolated class UserSettings: Sendable { final nonisolated class UserSettings: Sendable {
// TODO: @pixlwave Make this private after handling Advanced/Labs/Developer screen models.
let app: AppSettings let app: AppSettings
private let account: AccountSettings let account: AccountSettings
init(appSettings: AppSettings, accountSettings: AccountSettings) { init(appSettings: AppSettings, accountSettings: AccountSettings) {
self.app = appSettings app = appSettings
self.account = accountSettings account = accountSettings
} }
static func mock(userID: String = "@me:matrix.org") -> UserSettings { static func mock(userID: String = "@me:matrix.org") -> UserSettings {
AppSettings.volatile().userSettings(for: userID) AppSettings.volatile().userSettings(for: userID)
} }
subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<AppSettings, Value>) -> Value {
get { app[keyPath: keyPath] }
set { app[keyPath: keyPath] = newValue }
}
subscript<Value>(dynamicMember keyPath: KeyPath<AppSettings, Value>) -> Value {
app[keyPath: keyPath]
}
subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<AccountSettings, Value>) -> Value {
get { account[keyPath: keyPath] }
set { account[keyPath: keyPath] = newValue }
}
subscript<Value>(dynamicMember keyPath: KeyPath<AccountSettings, Value>) -> Value {
account[keyPath: keyPath]
}
func migrateAppSettingsValueToAccountSettings(_ keyPath: KeyPath<AccountSettings, UserPreferenceKey>) {
account.migrateAppSettingsValue(keyPath)
}
func resetSessionSpecificSettings() {
account.reset()
}
} }
@@ -94,7 +94,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
value == .verified, value == .verified,
stateMachine.state == .identityConfirmation else { return } stateMachine.state == .identityConfirmation else { return }
userSettings.hasRunIdentityConfirmationOnboarding = true userSettings.account.hasRunIdentityConfirmationOnboarding = true
stateMachine.tryEvent(.nextSkippingIdentityConfirmed) stateMachine.tryEvent(.nextSkippingIdentityConfirmed)
} }
} }
@@ -129,11 +129,11 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
private var requiresVerification: Bool { private var requiresVerification: Bool {
// We want to make sure onboarding finishes but also every time the user becomes unverified (e.g. account reset) // We want to make sure onboarding finishes but also every time the user becomes unverified (e.g. account reset)
!userSettings.hasRunIdentityConfirmationOnboarding || userSession.sessionSecurityStatePublisher.value.verificationState == .unverified !userSettings.account.hasRunIdentityConfirmationOnboarding || userSession.sessionSecurityStatePublisher.value.verificationState == .unverified
} }
private var requiresAppLockSetup: Bool { private var requiresAppLockSetup: Bool {
userSettings.appLockIsMandatory && !appLockService.isEnabled userSettings.app.appLockIsMandatory && !appLockService.isEnabled
} }
private var requiresAnalyticsSetup: Bool { private var requiresAnalyticsSetup: Bool {
@@ -141,7 +141,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
} }
private var requiresNotificationsSetup: Bool { private var requiresNotificationsSetup: Bool {
!userSettings.hasRunNotificationPermissionsOnboarding !userSettings.app.hasRunNotificationPermissionsOnboarding
} }
private func configureStateMachine() { private func configureStateMachine() {
@@ -225,7 +225,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
presentNotificationPermissionsScreen() presentNotificationPermissionsScreen()
case (_, _, .finished): case (_, _, .finished):
isNewLogin = false isNewLogin = false
userSettings.hasSignedInBefore = true userSettings.app.hasSignedInBefore = true
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
stateMachine.tryState(.initial) stateMachine.tryState(.initial)
case (.finished, _, .initial): case (.finished, _, .initial):
@@ -261,7 +261,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
case .recoveryKey: case .recoveryKey:
presentRecoveryKeyScreen() presentRecoveryKeyScreen()
case .skip: case .skip:
userSettings.hasRunIdentityConfirmationOnboarding = true userSettings.account.hasRunIdentityConfirmationOnboarding = true
stateMachine.tryEvent(.nextSkippingIdentityConfirmed) stateMachine.tryEvent(.nextSkippingIdentityConfirmed)
case .reset: case .reset:
startEncryptionResetFlow() startEncryptionResetFlow()
@@ -384,7 +384,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
} }
private func presentAnalyticsPromptScreen() { private func presentAnalyticsPromptScreen() {
let coordinator = AnalyticsPromptScreenCoordinator(analytics: analyticsService, termsURL: userSettings.analyticsTermsURL) let coordinator = AnalyticsPromptScreenCoordinator(analytics: analyticsService, termsURL: userSettings.app.analyticsTermsURL)
coordinator.actions coordinator.actions
.sink { [weak self] action in .sink { [weak self] action in
@@ -407,7 +407,7 @@ class OnboardingFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .done: case .done:
userSettings.hasRunNotificationPermissionsOnboarding = true userSettings.app.hasRunNotificationPermissionsOnboarding = true
stateMachine.tryEvent(.next) stateMachine.tryEvent(.next)
} }
} }
@@ -107,7 +107,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode, let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode,
mapURLBuilder: flowParameters.userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: flowParameters.userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager, liveLocationManager: flowParameters.userSession.liveLocationManager,
@@ -238,7 +238,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
defer { hideLoadingIndicator() } defer { hideLoadingIndicator() }
switch await roomProxy.loadOrFetchEventDetails(for: eventID) { switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event): case .success(let event):
if flowParameters.userSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { if flowParameters.userSettings.app.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last { if case .thread(threadRootEventID: threadRootEventID, _) = stateMachine.state, let threadCoordinator = childThreadScreenCoordinators.last {
threadCoordinator.focusOnEvent(eventID: eventID) threadCoordinator.focusOnEvent(eventID: eventID)
} else { } else {
@@ -318,7 +318,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// Otherwise check if the focussed event exists to handle a possible error or theaded event. // Otherwise check if the focussed event exists to handle a possible error or theaded event.
switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) { switch await roomProxy.loadOrFetchEventDetails(for: focusEvent.eventID) {
case .success(let event): case .success(let event):
if flowParameters.userSettings.threadsEnabled, let threadRootEventID = event.threadRootEventId() { if flowParameters.userSettings.app.threadsEnabled, let threadRootEventID = event.threadRootEventId() {
stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID, stateMachine.tryEvent(.presentRoom(presentationAction: .thread(rootEventID: threadRootEventID,
focusEvent: .init(eventID: focusEvent.eventID, focusEvent: .init(eventID: focusEvent.eventID,
shouldSetPin: focusEvent.shouldSetPin))), shouldSetPin: focusEvent.shouldSetPin))),
@@ -1119,8 +1119,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let parameters = MediaUploadPreviewScreenCoordinatorParameters(mediaURLs: mediaURLs, let parameters = MediaUploadPreviewScreenCoordinatorParameters(mediaURLs: mediaURLs,
caption: caption, caption: caption,
title: title, title: title,
shouldShowCaptionWarning: flowParameters.userSettings.shouldShowMediaCaptionWarning, shouldShowCaptionWarning: flowParameters.userSettings.app.shouldShowMediaCaptionWarning,
galleryEnabled: flowParameters.userSettings.galleryEnabled, galleryEnabled: flowParameters.userSettings.app.galleryEnabled,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: flowParameters.userSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: flowParameters.userSettings),
timelineController: timelineController, timelineController: timelineController,
clientProxy: userSession.clientProxy, clientProxy: userSession.clientProxy,
@@ -1176,7 +1176,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let stackCoordinator = NavigationStackCoordinator() let stackCoordinator = NavigationStackCoordinator()
let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode, let params = LocationSharingScreenCoordinatorParameters(interactionMode: interactionMode,
mapURLBuilder: flowParameters.userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: flowParameters.userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: timelineController, timelineController: timelineController,
liveLocationManager: flowParameters.userSession.liveLocationManager, liveLocationManager: flowParameters.userSession.liveLocationManager,
@@ -264,7 +264,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
} }
private func presentBlockedUsersScreen() { private func presentBlockedUsersScreen() {
let coordinator = BlockedUsersScreenCoordinator(parameters: .init(hideProfiles: flowParameters.userSettings.hideIgnoredUserProfiles, let coordinator = BlockedUsersScreenCoordinator(parameters: .init(hideProfiles: flowParameters.userSettings.app.hideIgnoredUserProfiles,
userSession: flowParameters.userSession, userSession: flowParameters.userSession,
userIndicatorController: flowParameters.userIndicatorController)) userIndicatorController: flowParameters.userIndicatorController))
navigationStackCoordinator.push(coordinator) navigationStackCoordinator.push(coordinator)
@@ -87,19 +87,19 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
navigationTabCoordinator = NavigationTabCoordinator() navigationTabCoordinator = NavigationTabCoordinator()
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator) navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: flowParameters.userSettings.hideBrandChrome)) let chatsSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: flowParameters.userSettings.app.hideBrandChrome))
chatsTabFlowCoordinator = ChatsTabFlowCoordinator(navigationSplitCoordinator: chatsSplitCoordinator, chatsTabFlowCoordinator = ChatsTabFlowCoordinator(navigationSplitCoordinator: chatsSplitCoordinator,
flowParameters: flowParameters) flowParameters: flowParameters)
chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid) chatsTabDetails = .init(tag: HomeTab.chats, title: L10n.screenHomeTabChats, icon: \.chat, selectedIcon: \.chatSolid)
chatsTabDetails.navigationSplitCoordinator = chatsSplitCoordinator chatsTabDetails.navigationSplitCoordinator = chatsSplitCoordinator
let spacesSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: flowParameters.userSettings.hideBrandChrome)) let spacesSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: flowParameters.userSettings.app.hideBrandChrome))
spacesTabFlowCoordinator = SpacesTabFlowCoordinator(navigationSplitCoordinator: spacesSplitCoordinator, spacesTabFlowCoordinator = SpacesTabFlowCoordinator(navigationSplitCoordinator: spacesSplitCoordinator,
flowParameters: flowParameters) flowParameters: flowParameters)
spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid) spacesTabDetails = .init(tag: HomeTab.spaces, title: L10n.screenHomeTabSpaces, icon: \.space, selectedIcon: \.spaceSolid)
spacesTabDetails.navigationSplitCoordinator = spacesSplitCoordinator spacesTabDetails.navigationSplitCoordinator = spacesSplitCoordinator
if flowParameters.userSettings.globalSearchEnabled, #available(iOS 26.0, *) { if flowParameters.userSettings.app.globalSearchEnabled, #available(iOS 26.0, *) {
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
clientProxy: flowParameters.userSession.clientProxy, clientProxy: flowParameters.userSession.clientProxy,
mediaProvider: flowParameters.userSession.mediaProvider, mediaProvider: flowParameters.userSession.mediaProvider,
@@ -463,8 +463,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
presentCallScreen(configuration: .init(roomProxy: roomProxy, presentCallScreen(configuration: .init(roomProxy: roomProxy,
clientProxy: userSession.clientProxy, clientProxy: userSession.clientProxy,
clientID: InfoPlistReader.main.bundleIdentifier, clientID: InfoPlistReader.main.bundleIdentifier,
elementCallBaseURL: flowParameters.userSettings.elementCallBaseURL, elementCallBaseURL: flowParameters.userSettings.app.elementCallBaseURL,
elementCallBaseURLOverride: flowParameters.userSettings.elementCallBaseURLOverride, elementCallBaseURLOverride: flowParameters.userSettings.app.elementCallBaseURLOverride,
voiceOnly: voiceOnly, voiceOnly: voiceOnly,
colorScheme: colorScheme)) colorScheme: colorScheme))
} }
@@ -56,7 +56,7 @@ private struct VisualListItemLabelStyle: LabelStyle {
// MARK: - Previews // MARK: - Previews
struct VisualListItem_Previews: PreviewProvider, TestablePreview { struct VisualListItem_Previews: PreviewProvider, TestablePreview {
static let strings = AnalyticsPromptScreenStrings(termsURL: UserSettings.mock().analyticsTermsURL) static let strings = AnalyticsPromptScreenStrings(termsURL: AppSettings.volatile().analyticsTermsURL)
static var testImage1: some View { static var testImage1: some View {
Image(systemName: "circle") Image(systemName: "circle")
@@ -165,13 +165,13 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
// We only set the analytics configuration if analytics are enabled // We only set the analytics configuration if analytics are enabled
let analyticsConfiguration: ElementCallAnalyticsConfiguration? = if analyticsService.isEnabled { let analyticsConfiguration: ElementCallAnalyticsConfiguration? = if analyticsService.isEnabled {
.init(posthogAPIHost: userSettings.elementCallPosthogAPIHost, .init(posthogAPIHost: userSettings.app.elementCallPosthogAPIHost,
posthogAPIKey: userSettings.elementCallPosthogAPIKey, posthogAPIKey: userSettings.app.elementCallPosthogAPIKey,
sentryDSN: userSettings.elementCallPosthogSentryDSN) sentryDSN: userSettings.app.elementCallPosthogSentryDSN)
} else { } else {
nil nil
} }
let rageshakeURL: String? = if case let .url(baseURL) = userSettings.bugReportRageshakeURL.publisher.value { let rageshakeURL: String? = if case let .url(baseURL) = userSettings.app.bugReportRageshakeURL.publisher.value {
baseURL.absoluteString baseURL.absoluteString
} else { } else {
nil nil
@@ -64,7 +64,7 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
shouldShowCancelButton: shouldShowCancelButton, shouldShowCancelButton: shouldShowCancelButton,
roomName: "", roomName: "",
serverName: userSession.clientProxy.userIDServerName ?? "", serverName: userSession.clientProxy.userIDServerName ?? "",
isKnockingFeatureEnabled: userSettings.knockingEnabled, isKnockingFeatureEnabled: userSettings.app.knockingEnabled,
canSelectSpace: canSelectSpace, canSelectSpace: canSelectSpace,
aliasLocalPart: roomAliasNameFromRoomDisplayName(roomName: ""), aliasLocalPart: roomAliasNameFromRoomDisplayName(roomName: ""),
bindings: bindings) bindings: bindings)
@@ -452,7 +452,7 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
selectionMode: CreateRoomScreenSpaceSelectionMode = .editableSpacesList(preSelectedSpace: nil), selectionMode: CreateRoomScreenSpaceSelectionMode = .editableSpacesList(preSelectedSpace: nil),
isAliasAvailable: Bool = true) -> CreateRoomScreenViewModel { isAliasAvailable: Bool = true) -> CreateRoomScreenViewModel {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.knockingEnabled = isKnockingEnabled userSettings.app.knockingEnabled = isKnockingEnabled
let clientProxy = ClientProxyMock(.init(userIDServerName: "example.org", let clientProxy = ClientProxyMock(.init(userIDServerName: "example.org",
userID: "@userid:example.com")) userID: "@userid:example.com"))
@@ -52,7 +52,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
bindings: .init(filtersState: .init(userSettings: userSettings))), bindings: .init(filtersState: .init(userSettings: userSettings))),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
if userSettings.globalSearchEnabled, #available(iOS 26.0, *) { if userSettings.app.globalSearchEnabled, #available(iOS 26.0, *) {
state.isRoomListSearchEnabled = false state.isRoomListSearchEnabled = false
} }
@@ -113,21 +113,21 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.selectedRoomID, on: self) .weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.showAllRoomListActivityPublisher userSettings.app.showAllRoomListActivityPublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.updateRooms() self?.updateRooms()
} }
.store(in: &cancellables) .store(in: &cancellables)
userSettings.seenInvitesPublisher userSettings.app.seenInvitesPublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.updateRooms() self?.updateRooms()
} }
.store(in: &cancellables) .store(in: &cancellables)
userSettings.hasSeenNewSoundBannerPublisher userSettings.app.hasSeenNewSoundBannerPublisher
.sink { [weak self] hasSeenNewSoundBanner in .sink { [weak self] hasSeenNewSoundBanner in
self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner self?.state.shouldShowNewSoundBanner = !hasSeenNewSoundBanner
} }
@@ -217,13 +217,13 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
case .skipRecoveryKeyConfirmation: case .skipRecoveryKeyConfirmation:
state.securityBannerMode = .dismissed state.securityBannerMode = .dismissed
case .dismissNewSoundBanner: case .dismissNewSoundBanner:
userSettings.hasSeenNewSoundBanner = true userSettings.app.hasSeenNewSoundBanner = true
case .screenAppeared: case .screenAppeared:
// Use a task otherwise the presentation isn't animated. // Use a task otherwise the presentation isn't animated.
Task { presentMultiAccountAnnouncementIfNeeded() } Task { presentMultiAccountAnnouncementIfNeeded() }
case .multiAccountAnnouncementAppeared: case .multiAccountAnnouncementAppeared:
MXLog.info("The multi-account announcement has been seen.") MXLog.info("The multi-account announcement has been seen.")
userSettings.hasSeenMultiAccountAnnouncement = true userSettings.app.hasSeenMultiAccountAnnouncement = true
case .dismissMultiAccountAnnouncement, .addAccount: // There is no add account flow to start yet. case .dismissMultiAccountAnnouncement, .addAccount: // There is no add account flow to start yet.
state.bindings.isPresentingMultiAccountAnnouncement = false state.bindings.isPresentingMultiAccountAnnouncement = false
case .updateVisibleItemRange(let range): case .updateVisibleItemRange(let range):
@@ -275,7 +275,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
case .success: case .success:
analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle) analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle)
if case .failure(let error) = await roomProxy.markAsRead(receiptType: userSettings.sharePresence ? .read : .readPrivate) { if case .failure(let error) = await roomProxy.markAsRead(receiptType: userSettings.app.sharePresence ? .read : .readPrivate) {
MXLog.error("Failed marking room \(roomIdentifier) as read with error: \(error)") MXLog.error("Failed marking room \(roomIdentifier) as read with error: \(error)")
} }
case .failure(let error): case .failure(let error):
@@ -389,11 +389,11 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
} }
var rooms = [HomeScreenRoom]() var rooms = [HomeScreenRoom]()
let seenInvites = userSettings.seenInvites let seenInvites = userSettings.app.seenInvites
for summary in roomSummaryProvider.roomListPublisher.value { for summary in roomSummaryProvider.roomListPublisher.value {
let room = HomeScreenRoom(summary: summary, let room = HomeScreenRoom(summary: summary,
showAllActivity: userSettings.showAllRoomListActivity, showAllActivity: userSettings.app.showAllRoomListActivity,
seenInvites: seenInvites) seenInvites: seenInvites)
rooms.append(room) rooms.append(room)
} }
@@ -488,8 +488,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private func presentMultiAccountAnnouncementIfNeeded() { private func presentMultiAccountAnnouncementIfNeeded() {
// An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed. // An unverified session gets the identity confirmation cover first, the room list appears again once it's dismissed.
guard userSettings.multiAccountEnabled, guard userSettings.app.multiAccountEnabled,
!userSettings.hasSeenMultiAccountAnnouncement, !userSettings.app.hasSeenMultiAccountAnnouncement,
userSession.sessionSecurityStatePublisher.value.verificationState == .verified, userSession.sessionSecurityStatePublisher.value.verificationState == .verified,
!state.bindings.isPresentingMultiAccountAnnouncement else { !state.bindings.isPresentingMultiAccountAnnouncement else {
return return
@@ -545,7 +545,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect, analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace, isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount)) activeMemberCount: UInt(roomProxy.info.activeMembersCount))
userSettings.seenInvites.remove(roomProxy.id) userSettings.app.seenInvites.remove(roomProxy.id)
} }
private func showDeclineInviteConfirmationAlert(roomID: String) async { private func showDeclineInviteConfirmationAlert(roomID: String) async {
@@ -595,7 +595,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
switch result { switch result {
case .success: case .success:
await notificationManager.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case. await notificationManager.removeDeliveredMessageNotifications(for: roomID) // Normally handled by the room flow, but that's never presented in this case.
userSettings.seenInvites.remove(roomID) userSettings.app.seenInvites.remove(roomID)
case .failure: case .failure:
displayError() displayError()
} }
@@ -99,11 +99,11 @@ struct RoomListFiltersState {
var availableFilters: [RoomListFilter] { var availableFilters: [RoomListFilter] {
var availableFilters = OrderedSet(RoomListFilter.availableFilters) var availableFilters = OrderedSet(RoomListFilter.availableFilters)
if !userSettings.lowPriorityFilterEnabled { if !userSettings.app.lowPriorityFilterEnabled {
availableFilters.remove(.lowPriority) availableFilters.remove(.lowPriority)
} }
if !userSettings.mentionsFilterEnabled { if !userSettings.app.mentionsFilterEnabled {
availableFilters.remove(.mentions) availableFilters.remove(.mentions)
} }
@@ -52,7 +52,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
.sink { mode in .sink { mode in
switch mode { switch mode {
case .invited: case .invited:
userSettings.seenInvites.insert(roomID) userSettings.app.seenInvites.insert(roomID)
default: default:
break break
} }
@@ -350,7 +350,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
guard !hasSentJoinAction else { return } guard !hasSentJoinAction else { return }
let roomID = state.roomID let roomID = state.roomID
userSettings.seenInvites.remove(roomID) userSettings.app.seenInvites.remove(roomID)
guard state.roomDetails?.isSpace == true else { guard state.roomDetails?.isSpace == true else {
hasSentJoinAction = true hasSentJoinAction = true
@@ -464,7 +464,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
return false return false
} }
userSettings.seenInvites.remove(roomID) userSettings.app.seenInvites.remove(roomID)
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
return true return true
@@ -173,7 +173,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
.store(in: &cancellables) .store(in: &cancellables)
userSettings.liveLocationSharingSessionsByRoomIDPublisher userSettings.app.liveLocationSharingSessionsByRoomIDPublisher
.map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil } .map { [roomID = roomProxy.id] sessions in sessions[roomID] != nil }
.removeDuplicates() .removeDuplicates()
.sink { [weak self] isSharingLiveLocationOnThisDevice in .sink { [weak self] isSharingLiveLocationOnThisDevice in
@@ -261,14 +261,14 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
private func showLiveLocationFlow() { private func showLiveLocationFlow() {
if userSettings.liveLocationDisclaimerDisplayed { if userSettings.app.liveLocationDisclaimerDisplayed {
showLiveLocationDurationPicker() showLiveLocationDurationPicker()
} else { } else {
state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer, state.bindings.alertInfo = .init(alertID: .liveLocationDisclaimer,
primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil), primaryButton: .init(title: L10n.actionDecline, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionAccept) { [weak self] in secondaryButton: .init(title: L10n.actionAccept) { [weak self] in
guard let self else { return } guard let self else { return }
userSettings.liveLocationDisclaimerDisplayed = true userSettings.app.liveLocationDisclaimerDisplayed = true
// Delay so SwiftUI finishes dismissing the current alert // Delay so SwiftUI finishes dismissing the current alert
// before presenting the next one. // before presenting the next one.
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -435,7 +435,7 @@ extension LocationSharingScreenViewModel {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
return LocationSharingScreenViewModel(interactionMode: interactionMode, return LocationSharingScreenViewModel(interactionMode: interactionMode,
mapURLBuilder: userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: roomProxy, roomProxy: roomProxy,
timelineController: TimelineControllerMock(.init()), timelineController: TimelineControllerMock(.init()),
liveLocationManager: LiveLocationManagerMock(), liveLocationManager: LiveLocationManagerMock(),
@@ -102,6 +102,6 @@ struct AnalyticsPromptScreen_Previews: PreviewProvider, TestablePreview {
} }
static func makeViewModel(showTerms: Bool = true) -> AnalyticsPromptScreenViewModel { static func makeViewModel(showTerms: Bool = true) -> AnalyticsPromptScreenViewModel {
AnalyticsPromptScreenViewModel(termsURL: showTerms ? UserSettings.mock().analyticsTermsURL : nil) AnalyticsPromptScreenViewModel(termsURL: showTerms ? AppSettings.volatile().analyticsTermsURL : nil)
} }
} }
@@ -24,7 +24,7 @@ class IdentityConfirmationScreenViewModel: IdentityConfirmationScreenViewModelTy
self.userSession = userSession self.userSession = userSession
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
super.init(initialViewState: IdentityConfirmationScreenViewState(learnMoreURL: userSettings.deviceVerificationURL)) super.init(initialViewState: IdentityConfirmationScreenViewState(learnMoreURL: userSettings.app.deviceVerificationURL))
Task { [weak self] in Task { [weak self] in
for await state in userSession.sessionSecurityStatePublisher.values { for await state in userSession.sessionSecurityStatePublisher.values {
@@ -34,7 +34,7 @@ class SessionVerificationScreenViewModel: SessionVerificationViewModelType, Sess
stateMachine = SessionVerificationScreenStateMachine(state: verificationState) stateMachine = SessionVerificationScreenStateMachine(state: verificationState)
super.init(initialViewState: .init(flow: flow, super.init(initialViewState: .init(flow: flow,
learnMoreURL: userSettings.encryptionURL, learnMoreURL: userSettings.app.encryptionURL,
verificationState: verificationState), verificationState: verificationState),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
@@ -77,7 +77,7 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy
private func viewInRoomTimeline(eventID: String) async { private func viewInRoomTimeline(eventID: String) async {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) { switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event): case .success(let event):
let threadRootEventID: String? = if userSettings.threadsEnabled { let threadRootEventID: String? = if userSettings.app.threadsEnabled {
event.threadRootEventId() event.threadRootEventId()
} else { } else {
nil nil
@@ -107,7 +107,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
let timelineController = TimelineControllerMock(.init(timelineKind: .pinned, timelineItems: timelineItems)) let timelineController = TimelineControllerMock(.init(timelineKind: .pinned, timelineItems: timelineItems))
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.messageMultiSelectEnabled = isSelecting userSettings.app.messageMultiSelectEnabled = isSelecting
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let timelineViewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room", pinnedEventIDs: Set(eventIDs))), let timelineViewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room", pinnedEventIDs: Set(eventIDs))),
@@ -68,7 +68,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
topicSummary: topic?.unattributedStringByReplacingNewlinesWithSpaces(), topicSummary: topic?.unattributedStringByReplacingNewlinesWithSpaces(),
joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount, joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount,
notificationSettingsState: .loading, notificationSettingsState: .loading,
isNativeCallingEnabled: userSettings.nativeCallEnabled, isNativeCallingEnabled: userSettings.app.nativeCallEnabled,
bindings: .init()) bindings: .init())
super.init(initialViewState: appHooks.roomDetailsScreenHook.update(viewState), super.init(initialViewState: appHooks.roomDetailsScreenHook.update(viewState),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
@@ -80,7 +80,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
super.init(initialViewState: ComposerToolbarViewState(wysiwygViewModel: wysiwygViewModel, super.init(initialViewState: ComposerToolbarViewState(wysiwygViewModel: wysiwygViewModel,
isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted, isRoomEncrypted: roomProxy.infoPublisher.value.isEncrypted,
isLocationSharingEnabled: userSettings.mapTilerConfiguration.publisher.value.isEnabled, isLocationSharingEnabled: userSettings.app.mapTilerConfiguration.publisher.value.isEnabled,
bindings: .init()), bindings: .init()),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
@@ -122,13 +122,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayReportContent(let itemID, let senderID): case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker: case .displayCameraPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayMediaPicker: case .displayMediaPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayDocumentPicker: case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents(), selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents(), selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayMediaPreview(let mediaPreviewViewModel): case .displayMediaPreview(let mediaPreviewViewModel):
roomViewModel.displayMediaPreview(mediaPreviewViewModel) roomViewModel.displayMediaPreview(mediaPreviewViewModel)
@@ -68,7 +68,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayNameOrID, let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayNameOrID,
roomAvatar: roomProxy.infoPublisher.value.avatar, roomAvatar: roomProxy.infoPublisher.value.avatar,
isNativeCallingEnabled: userSettings.nativeCallEnabled, isNativeCallingEnabled: userSettings.app.nativeCallEnabled,
hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall, hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall,
isDM: roomProxy.infoPublisher.value.isDM, isDM: roomProxy.infoPublisher.value.isDM,
hasSuccessor: roomProxy.infoPublisher.value.successor != nil, hasSuccessor: roomProxy.infoPublisher.value.successor != nil,
@@ -127,7 +127,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { Task {
// When navigating away from the room, we need to mark the room as both read // When navigating away from the room, we need to mark the room as both read
// and fully read for Synapse to clear this room from the app's badge count. // and fully read for Synapse to clear this room from the app's badge count.
_ = await roomProxy.markAsRead(receiptType: userSettings.sharePresence ? .read : .readPrivate) _ = await roomProxy.markAsRead(receiptType: userSettings.app.sharePresence ? .read : .readPrivate)
_ = await roomProxy.markAsRead(receiptType: .fullyRead) _ = await roomProxy.markAsRead(receiptType: .fullyRead)
} }
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier. // Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
@@ -166,11 +166,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Private // MARK: - Private
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) { private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
userSettings.threadsEnabledPublisher userSettings.app.threadsEnabledPublisher
.weakAssign(to: \.state.roomThreadListEnabled, on: self) .weakAssign(to: \.state.roomThreadListEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.liveLocationSharingSessionsByRoomIDPublisher userSettings.app.liveLocationSharingSessionsByRoomIDPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] sessionsByRoomID in .sink { [weak self] sessionsByRoomID in
guard let self else { return } guard let self else { return }
@@ -264,10 +264,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
if let member = identityVerificationViolations.values.first { if let member = identityVerificationViolations.values.first {
state.footerDetails = .verificationViolation(member: member, state.footerDetails = .verificationViolation(member: member,
learnMoreURL: userSettings.identityPinningViolationDetailsURL) learnMoreURL: userSettings.app.identityPinningViolationDetailsURL)
} else if let member = identityPinningViolations.values.first { } else if let member = identityPinningViolations.values.first {
state.footerDetails = .pinViolation(member: member, state.footerDetails = .pinViolation(member: member,
learnMoreURL: userSettings.identityPinningViolationDetailsURL) learnMoreURL: userSettings.app.identityPinningViolationDetailsURL)
} else { } else {
state.footerDetails = nil state.footerDetails = nil
} }
@@ -428,7 +428,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { Task {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) { switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event): case .success(let event):
if userSettings.threadsEnabled, if userSettings.app.threadsEnabled,
let threadRootEventID = event.threadRootEventId() { let threadRootEventID = event.threadRootEventId() {
actionsSubject.send(.focusEvent(eventID: threadRootEventID)) actionsSubject.send(.focusEvent(eventID: threadRootEventID))
actionsSubject.send(.displayThread(threadRootEventID: threadRootEventID, focussedEventID: eventID)) actionsSubject.send(.displayThread(threadRootEventID: threadRootEventID, focussedEventID: eventID))
@@ -396,7 +396,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.messageMultiSelectEnabled = isSelecting userSettings.app.messageMultiSelectEnabled = isSelecting
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let timelineController = TimelineControllerMock(.init()) let timelineController = TimelineControllerMock(.init())
@@ -73,7 +73,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
.store(in: &cancellables) .store(in: &cancellables)
// The room list is empty on a cold start, so the room breadcrumbs are re-resolved as it loads. // The room list is empty on a cold start, so the room breadcrumbs are re-resolved as it loads.
userSettings.searchBreadcrumbsPublisher userSettings.account.searchBreadcrumbsPublisher
.combineLatest(clientProxy.staticRoomSummaryProvider.roomListPublisher) .combineLatest(clientProxy.staticRoomSummaryProvider.roomListPublisher)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] storedBreadcrumbs, _ in .sink { [weak self] storedBreadcrumbs, _ in
@@ -122,7 +122,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
} }
updateRooms(with: roomSummaryProvider.roomListPublisher.value) updateRooms(with: roomSummaryProvider.roomListPublisher.value)
state.breadcrumbs = makeBreadcrumbs(from: userSettings.searchBreadcrumbs) state.breadcrumbs = makeBreadcrumbs(from: userSettings.account.searchBreadcrumbs)
} }
isolated deinit { isolated deinit {
@@ -154,7 +154,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
case .removeBreadcrumb(let breadcrumb): case .removeBreadcrumb(let breadcrumb):
removeBreadcrumb(breadcrumb) removeBreadcrumb(breadcrumb)
case .clearBreadcrumbs: case .clearBreadcrumbs:
userSettings.searchBreadcrumbs = [] userSettings.account.searchBreadcrumbs = []
case .reachedTop: case .reachedTop:
if state.bindings.searchMode == .rooms { if state.bindings.searchMode == .rooms {
updateVisibleRange(edge: .top) updateVisibleRange(edge: .top)
@@ -223,11 +223,11 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
/// Moves the breadcrumb to the top of the history, adding it if needed. /// Moves the breadcrumb to the top of the history, adding it if needed.
private func recordBreadcrumb(_ breadcrumb: SearchBreadcrumb) { private func recordBreadcrumb(_ breadcrumb: SearchBreadcrumb) {
var breadcrumbs = userSettings.searchBreadcrumbs var breadcrumbs = userSettings.account.searchBreadcrumbs
breadcrumbs.removeAll { $0 == breadcrumb } breadcrumbs.removeAll { $0 == breadcrumb }
breadcrumbs.insert(breadcrumb, at: 0) breadcrumbs.insert(breadcrumb, at: 0)
userSettings.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount)) userSettings.account.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount))
} }
private func removeBreadcrumb(_ breadcrumb: SearchScreenBreadcrumb) { private func removeBreadcrumb(_ breadcrumb: SearchScreenBreadcrumb) {
@@ -235,7 +235,7 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
case .query(let query): .query(query) case .query(let query): .query(query)
case .room(let room): .room(roomID: room.id) case .room(let room): .room(roomID: room.id)
} }
userSettings.searchBreadcrumbs.removeAll { $0 == storedBreadcrumb } userSettings.account.searchBreadcrumbs.removeAll { $0 == storedBreadcrumb }
} }
private func setActiveTabLoading(_ isLoading: Bool) { private func setActiveTabLoading(_ isLoading: Bool) {
@@ -709,7 +709,7 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
private static func makeUserSettings(breadcrumbs: [SearchBreadcrumb]) -> UserSettings { private static func makeUserSettings(breadcrumbs: [SearchBreadcrumb]) -> UserSettings {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.searchBreadcrumbs = breadcrumbs userSettings.account.searchBreadcrumbs = breadcrumbs
return userSettings return userSettings
} }
@@ -33,7 +33,7 @@ final class SecureBackupScreenCoordinator: CoordinatorProtocol {
init(parameters: SecureBackupScreenCoordinatorParameters) { init(parameters: SecureBackupScreenCoordinatorParameters) {
viewModel = SecureBackupScreenViewModel(secureBackupController: parameters.clientProxy.secureBackupController, viewModel = SecureBackupScreenViewModel(secureBackupController: parameters.clientProxy.secureBackupController,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: parameters.userIndicatorController,
chatBackupDetailsURL: parameters.userSettings.chatBackupDetailsURL) chatBackupDetailsURL: parameters.userSettings.app.chatBackupDetailsURL)
} }
func start() { func start() {
@@ -37,8 +37,8 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
isEncryptionEnabled: roomProxy.infoPublisher.value.isEncrypted, isEncryptionEnabled: roomProxy.infoPublisher.value.isEncrypted,
historyVisibility: roomProxy.infoPublisher.value.historyVisibility.toSecurityAndPrivacyHistoryVisibility, historyVisibility: roomProxy.infoPublisher.value.historyVisibility.toSecurityAndPrivacyHistoryVisibility,
isSpace: roomProxy.infoPublisher.value.isSpace, isSpace: roomProxy.infoPublisher.value.isSpace,
isKnockingEnabled: userSettings.knockingEnabled, isKnockingEnabled: userSettings.app.knockingEnabled,
historySharingDetailsURL: userSettings.historySharingDetailsURL) historySharingDetailsURL: userSettings.app.historySharingDetailsURL)
super.init(initialViewState: appHooks.securityAndPrivacyScreenHook.update(viewState, homeserver: clientProxy.homeserver)) super.init(initialViewState: appHooks.securityAndPrivacyScreenHook.update(viewState, homeserver: clientProxy.homeserver))
@@ -154,7 +154,7 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
.weakAssign(to: \.state.isSpace, on: self) .weakAssign(to: \.state.isSpace, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.knockingEnabledPublisher userSettings.app.knockingEnabledPublisher
.weakAssign(to: \.state.isKnockingEnabled, on: self) .weakAssign(to: \.state.isKnockingEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
} }
@@ -163,7 +163,7 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
state.canEditAddress = powerLevels.canOwnUser(sendStateEvent: .roomCanonicalAlias) state.canEditAddress = powerLevels.canOwnUser(sendStateEvent: .roomCanonicalAlias)
state.canEditJoinRule = powerLevels.canOwnUser(sendStateEvent: .roomJoinRules) state.canEditJoinRule = powerLevels.canOwnUser(sendStateEvent: .roomJoinRules)
state.canEditHistoryVisibility = powerLevels.canOwnUser(sendStateEvent: .roomHistoryVisibility) state.canEditHistoryVisibility = powerLevels.canOwnUser(sendStateEvent: .roomHistoryVisibility)
state.canEnableEncryption = powerLevels.canOwnUser(sendStateEvent: .roomEncryption) && !userSettings.forceDisableE2EE.publisher.value state.canEnableEncryption = powerLevels.canOwnUser(sendStateEvent: .roomEncryption) && !userSettings.app.forceDisableE2EE.publisher.value
} }
private func setupRoomDirectoryVisibility() { private func setupRoomDirectoryVisibility() {
@@ -292,7 +292,7 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
static let askToJoinViewModel = { static let askToJoinViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false, return SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org", canonicalAlias: "#room:matrix.org",
@@ -307,7 +307,7 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
static let singleAskToJoinSpaceMembersViewModel = { static let singleAskToJoinSpaceMembersViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
let space = [SpaceServiceRoom].mockSingleRoom[0] let space = [SpaceServiceRoom].mockSingleRoom[0]
@@ -325,7 +325,7 @@ struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
static let multipleAskToJoinSpacesMembersViewModel = { static let multipleAskToJoinSpacesMembersViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
let spaces = [SpaceServiceRoom].mockJoinedSpaces let spaces = [SpaceServiceRoom].mockJoinedSpaces
@@ -31,7 +31,7 @@ class OAuthAccountSettingsPresenter: NSObject {
appHooks: AppHooks, appHooks: AppHooks,
continuation: Continuation? = nil) { continuation: Continuation? = nil) {
self.accountURL = accountURL self.accountURL = accountURL
redirectURL = userSettings.oAuthRedirectURL redirectURL = userSettings.app.oAuthRedirectURL
self.presentationAnchor = presentationAnchor self.presentationAnchor = presentationAnchor
self.appMediator = appMediator self.appMediator = appMediator
self.appHooks = appHooks self.appHooks = appHooks
@@ -17,13 +17,13 @@ class AnalyticsSettingsScreenViewModel: AnalyticsSettingsScreenViewModelType, An
init(userSettings: UserSettings, analytics: AnalyticsServiceProtocol) { init(userSettings: UserSettings, analytics: AnalyticsServiceProtocol) {
self.analytics = analytics self.analytics = analytics
let strings = AnalyticsSettingsScreenStrings(termsURL: userSettings.analyticsTermsURL) let strings = AnalyticsSettingsScreenStrings(termsURL: userSettings.app.analyticsTermsURL)
let bindings = AnalyticsSettingsScreenViewStateBindings(enableAnalytics: analytics.isEnabled) let bindings = AnalyticsSettingsScreenViewStateBindings(enableAnalytics: analytics.isEnabled)
let state = AnalyticsSettingsScreenViewState(strings: strings, bindings: bindings) let state = AnalyticsSettingsScreenViewState(strings: strings, bindings: bindings)
super.init(initialViewState: state) super.init(initialViewState: state)
userSettings.analyticsConsentStatePublisher userSettings.app.analyticsConsentStatePublisher
.map { $0 == .optedIn } .map { $0 == .optedIn }
.weakAssign(to: \.state.bindings.enableAnalytics, on: self) .weakAssign(to: \.state.bindings.enableAnalytics, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -13,8 +13,8 @@ typealias LegalInformationScreenViewModelType = StateStoreViewModelV2<LegalInfor
class LegalInformationScreenViewModel: LegalInformationScreenViewModelType, LegalInformationScreenViewModelProtocol { class LegalInformationScreenViewModel: LegalInformationScreenViewModelType, LegalInformationScreenViewModelProtocol {
init(userSettings: UserSettings) { init(userSettings: UserSettings) {
super.init(initialViewState: LegalInformationScreenViewState(copyrightURL: userSettings.copyrightURL, super.init(initialViewState: LegalInformationScreenViewState(copyrightURL: userSettings.app.copyrightURL,
acceptableUseURL: userSettings.acceptableUseURL, acceptableUseURL: userSettings.app.acceptableUseURL,
privacyURL: userSettings.privacyURL)) privacyURL: userSettings.app.privacyURL))
} }
} }
@@ -33,8 +33,8 @@ struct LocationSharingSettingsScreenViewStateBindings {
} }
var liveLocationMinimumDistanceUpdate: Int { var liveLocationMinimumDistanceUpdate: Int {
get { userSettings.liveLocationMinimumDistanceUpdate } get { userSettings.app.liveLocationMinimumDistanceUpdate }
set { userSettings.liveLocationMinimumDistanceUpdate = newValue } set { userSettings.app.liveLocationMinimumDistanceUpdate = newValue }
} }
} }
@@ -20,8 +20,8 @@ struct MediaUploadQualitySettingsScreenViewStateBindings {
} }
var optimizeMediaUploads: Bool { var optimizeMediaUploads: Bool {
get { userSettings.optimizeMediaUploads } get { userSettings.app.optimizeMediaUploads }
set { userSettings.optimizeMediaUploads = newValue } set { userSettings.app.optimizeMediaUploads = newValue }
} }
} }
@@ -32,8 +32,8 @@ struct ModerationAndSafetySettingsScreenViewStateBindings {
} }
var sharePresence: Bool { var sharePresence: Bool {
get { userSettings.sharePresence } get { userSettings.app.sharePresence }
set { userSettings.sharePresence = newValue } set { userSettings.app.sharePresence = newValue }
} }
} }
@@ -38,21 +38,21 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
notificationTonePreviewer = AudioPlayer() notificationTonePreviewer = AudioPlayer()
self.notificationToneManager = notificationToneManager self.notificationToneManager = notificationToneManager
let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: userSettings.enableNotifications, let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: userSettings.app.enableNotifications,
showAllRoomListActivity: userSettings.showAllRoomListActivity) showAllRoomListActivity: userSettings.app.showAllRoomListActivity)
super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings, super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings,
isModallyPresented: isModallyPresented, isModallyPresented: isModallyPresented,
selectedAlertTone: userSettings.selectedNotificationTone ?? NotificationToneManager.defaultElementXMessageTone, selectedAlertTone: userSettings.app.selectedNotificationTone ?? NotificationToneManager.defaultElementXMessageTone,
availableCustomTones: notificationToneManager.customTones(), availableCustomTones: notificationToneManager.customTones(),
// macos lacks default sounds and its sandbox Sounds directory is immutable // macos lacks default sounds and its sandbox Sounds directory is immutable
customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac)) customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac))
// Listen for changes to UserSettings. // Listen for changes to UserSettings.
userSettings.enableNotificationsPublisher userSettings.app.enableNotificationsPublisher
.weakAssign(to: \.state.bindings.enableNotifications, on: self) .weakAssign(to: \.state.bindings.enableNotifications, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.selectedNotificationTonePublisher userSettings.app.selectedNotificationTonePublisher
.map { $0 ?? NotificationToneManager.defaultElementXMessageTone } .map { $0 ?? NotificationToneManager.defaultElementXMessageTone }
.weakAssign(to: \.state.selectedAlertTone, on: self) .weakAssign(to: \.state.selectedAlertTone, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -104,7 +104,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
} }
Task { await enableInvitations(state.bindings.invitationsEnabled) } Task { await enableInvitations(state.bindings.invitationsEnabled) }
case .showAllRoomListActivityChanged: case .showAllRoomListActivityChanged:
userSettings.showAllRoomListActivity = state.bindings.showAllRoomListActivity userSettings.app.showAllRoomListActivity = state.bindings.showAllRoomListActivity
case .close: case .close:
actionsSubject.send(.close) actionsSubject.send(.close)
case .fixConfigurationMismatchTapped: case .fixConfigurationMismatchTapped:
@@ -127,7 +127,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
} }
func toggleNotifications() { func toggleNotifications() {
userSettings.enableNotifications.toggle() userSettings.app.enableNotifications.toggle()
} }
private func setupDidBecomeActiveSubscription() { private func setupDidBecomeActiveSubscription() {
@@ -302,7 +302,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
try notificationToneManager.deleteCustomTone(tone) try notificationToneManager.deleteCustomTone(tone)
if tone == state.selectedAlertTone { if tone == state.selectedAlertTone {
userSettings.selectedNotificationTone = nil userSettings.app.selectedNotificationTone = nil
} }
} catch { } catch {
MXLog.error("Error deleting alert tone \(tone.label): \(error)") MXLog.error("Error deleting alert tone \(tone.label): \(error)")
@@ -89,8 +89,8 @@ struct SettingsScreenViewStateBindings {
var isPresentingAccountDeactivationConfirmation = false var isPresentingAccountDeactivationConfirmation = false
var appAppearance: AppAppearance { var appAppearance: AppAppearance {
get { userSettings.appAppearance } get { userSettings.app.appAppearance }
set { userSettings.appAppearance = newValue } set { userSettings.app.appAppearance = newValue }
} }
init(userSettings: UserSettings) { init(userSettings: UserSettings) {
@@ -29,25 +29,25 @@ 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.app.linkNewDeviceEnabled,
showAddAccountButton: userSettings.multiAccountEnabled, showAddAccountButton: userSettings.app.multiAccountEnabled,
showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount,
showDeveloperOptions: userSettings.developerOptionsEnabled, showDeveloperOptions: userSettings.app.developerOptionsEnabled,
showAnalyticsSettings: userSettings.canPromptForAnalytics, showAnalyticsSettings: userSettings.app.canPromptForAnalytics,
isBugReportServiceEnabled: isBugReportServiceEnabled, isBugReportServiceEnabled: isBugReportServiceEnabled,
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic, navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic,
bindings: .init(userSettings: userSettings)), bindings: .init(userSettings: userSettings)),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
userSettings.developerOptionsEnabledPublisher userSettings.app.developerOptionsEnabledPublisher
.weakAssign(to: \.state.showDeveloperOptions, on: self) .weakAssign(to: \.state.showDeveloperOptions, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.linkNewDeviceEnabledPublisher userSettings.app.linkNewDeviceEnabledPublisher
.weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .weakAssign(to: \.state.showLinkNewDeviceButton, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.multiAccountEnabledPublisher userSettings.app.multiAccountEnabledPublisher
.weakAssign(to: \.state.showAddAccountButton, on: self) .weakAssign(to: \.state.showAddAccountButton, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -144,7 +144,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
actionsSubject.send(.about) actionsSubject.send(.about)
case .enableDeveloperOptions: case .enableDeveloperOptions:
userSettings.developerOptionsEnabled.toggle() userSettings.app.developerOptionsEnabled.toggle()
case .developerOptions: case .developerOptions:
actionsSubject.send(.developerOptions) actionsSubject.send(.developerOptions)
} }
@@ -318,8 +318,8 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
displayName: "Alice Liddell", displayName: "Alice Liddell",
status: .mockFocussing)))) status: .mockFocussing))))
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.multiAccountEnabled = true userSettings.app.multiAccountEnabled = true
userSettings.linkNewDeviceEnabled = true userSettings.app.linkNewDeviceEnabled = true
return SettingsScreenViewModel(userSession: userSession, return SettingsScreenViewModel(userSession: userSession,
userSettings: userSettings, userSettings: userSettings,
isBugReportServiceEnabled: isBugReportServiceEnabled, isBugReportServiceEnabled: isBugReportServiceEnabled,
@@ -109,13 +109,13 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
case .displayReportContent(let itemID, let senderID): case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID)) actionsSubject.send(.presentReportContent(itemID: itemID, senderID: senderID))
case .displayCameraPicker: case .displayCameraPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .camera, selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayMediaPicker: case .displayMediaPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .photoLibrary, selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayDocumentPicker: case .displayDocumentPicker:
actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents(), selectionType: .multiple(galleryEnabled: userSettings.galleryEnabled)), actionsSubject.send(.presentMediaUploadPicker(mode: .init(source: .documents(), selectionType: .multiple(galleryEnabled: userSettings.app.galleryEnabled)),
caption: composerViewModel.context.plainComposerText)) caption: composerViewModel.context.plainComposerText))
case .displayMediaPreview(let mediaPreviewViewModel): case .displayMediaPreview(let mediaPreviewViewModel):
viewModel.displayMediaPreview(mediaPreviewViewModel) viewModel.displayMediaPreview(mediaPreviewViewModel)
@@ -455,8 +455,8 @@ class TimelineInteractionHandler {
// MARK: Audio Playback // MARK: Audio Playback
func changePlaybackSpeed(for itemID: TimelineItemIdentifier) { func changePlaybackSpeed(for itemID: TimelineItemIdentifier) {
let nextSpeed = userSettings.voiceMessagePlaybackSpeed.next let nextSpeed = userSettings.app.voiceMessagePlaybackSpeed.next
userSettings.voiceMessagePlaybackSpeed = nextSpeed userSettings.app.voiceMessagePlaybackSpeed = nextSpeed
audioPlayerState(for: itemID)?.setPlaybackSpeed(nextSpeed) audioPlayerState(for: itemID)?.setPlaybackSpeed(nextSpeed)
} }
@@ -588,8 +588,8 @@ class TimelineInteractionHandler {
title: L10n.commonVoiceMessage, title: L10n.commonVoiceMessage,
duration: voiceMessageRoomTimelineItem.content.duration, duration: voiceMessageRoomTimelineItem.content.duration,
waveform: voiceMessageRoomTimelineItem.content.waveform, waveform: voiceMessageRoomTimelineItem.content.waveform,
playbackSpeed: userSettings.voiceMessagePlaybackSpeed, playbackSpeed: userSettings.app.voiceMessagePlaybackSpeed,
playbackSpeedPublisher: userSettings.voiceMessagePlaybackSpeedPublisher) playbackSpeedPublisher: userSettings.app.voiceMessagePlaybackSpeedPublisher)
mediaPlayerProvider.register(audioPlayerState: playerState) mediaPlayerProvider.register(audioPlayerState: playerState)
return playerState return playerState
} }
@@ -97,16 +97,16 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }), timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID, ownUserID: roomProxy.ownUserID,
hideTimelineMedia: hideTimelineMedia, hideTimelineMedia: hideTimelineMedia,
isViewSourceEnabled: userSettings.viewSourceEnabled, isViewSourceEnabled: userSettings.app.viewSourceEnabled,
areThreadsEnabled: userSettings.threadsEnabled, areThreadsEnabled: userSettings.app.threadsEnabled,
linkPreviewsEnabled: userSettings.linkPreviewsEnabled, linkPreviewsEnabled: userSettings.app.linkPreviewsEnabled,
jumpToReadMarkerEnabled: userSettings.jumpToReadMarkerEnabled, jumpToReadMarkerEnabled: userSettings.app.jumpToReadMarkerEnabled,
messageSelection: .init(isEnabled: userSettings.messageMultiSelectEnabled), messageSelection: .init(isEnabled: userSettings.app.messageMultiSelectEnabled),
hasPredecessor: roomProxy.predecessorRoom != nil, hasPredecessor: roomProxy.predecessorRoom != nil,
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs, pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
emojiProvider: emojiProvider, emojiProvider: emojiProvider,
linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider, linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider,
mapTilerConfiguration: userSettings.mapTilerConfiguration.publisher.value, mapTilerConfiguration: userSettings.app.mapTilerConfiguration.publisher.value,
bindings: .init(reactionsCollapsed: [:])), bindings: .init(reactionsCollapsed: [:])),
mediaProvider: userSession.mediaProvider, mediaProvider: userSession.mediaProvider,
contentScannerService: userSession.contentScannerService) contentScannerService: userSession.contentScannerService)
@@ -272,7 +272,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .voiceMessage(let voiceMessageAction): case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction) processVoiceMessageAction(voiceMessageAction)
case .contentChanged(let isEmpty): case .contentChanged(let isEmpty):
guard userSettings.sharePresence else { guard userSettings.app.sharePresence else {
return return
} }
@@ -508,7 +508,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
roomProxy.typingMembersPublisher roomProxy.typingMembersPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.filter { [weak self] _ in self?.userSettings.sharePresence ?? false } .filter { [weak self] _ in self?.userSettings.app.sharePresence ?? false }
.weakAssign(to: \.state.typingMembers, on: self) .weakAssign(to: \.state.typingMembers, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -560,7 +560,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
func viewInRoomTimeline(eventID: String) async { func viewInRoomTimeline(eventID: String) async {
switch await roomProxy.loadOrFetchEventDetails(for: eventID) { switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
case .success(let event): case .success(let event):
let threadRootEventID: String? = if userSettings.threadsEnabled { let threadRootEventID: String? = if userSettings.app.threadsEnabled {
event.threadRootEventId() event.threadRootEventId()
} else { } else {
nil nil
@@ -572,19 +572,19 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
private func setupUserSettingsSubscriptions() { private func setupUserSettingsSubscriptions() {
userSettings.sharePresencePublisher userSettings.app.sharePresencePublisher
.weakAssign(to: \.state.showReadReceipts, on: self) .weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.viewSourceEnabledPublisher userSettings.app.viewSourceEnabledPublisher
.weakAssign(to: \.state.isViewSourceEnabled, on: self) .weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.threadsEnabledPublisher userSettings.app.threadsEnabledPublisher
.weakAssign(to: \.state.areThreadsEnabled, on: self) .weakAssign(to: \.state.areThreadsEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.jumpToReadMarkerEnabledPublisher userSettings.app.jumpToReadMarkerEnabledPublisher
.weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self) .weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -1145,7 +1145,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
primaryButton: .init(title: L10n.actionOk, action: nil), primaryButton: .init(title: L10n.actionOk, action: nil),
secondaryButton: .init(title: L10n.actionLearnMore) { [weak self] in secondaryButton: .init(title: L10n.actionLearnMore) { [weak self] in
guard let self else { return } guard let self else { return }
appMediator.open(userSettings.historySharingDetailsURL) appMediator.open(userSettings.app.historySharingDetailsURL)
}) })
case .inviteAgain: case .inviteAgain:
state.bindings.alertInfo = .init(id: .inviteAgain, state.bindings.alertInfo = .init(id: .inviteAgain,
@@ -1196,7 +1196,7 @@ extension TimelineViewModel {
extension TimelineViewModel { extension TimelineViewModel {
private func setupSelectionSubscriptions() { private func setupSelectionSubscriptions() {
userSettings.messageMultiSelectEnabledPublisher userSettings.app.messageMultiSelectEnabledPublisher
.sink { [weak self] isEnabled in .sink { [weak self] isEnabled in
self?.state.messageSelection.isEnabled = isEnabled self?.state.messageSelection.isEnabled = isEnabled
if !isEnabled { if !isEnabled {
@@ -427,7 +427,7 @@ private extension TimelineItemKeyForwarder {
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview { struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel: TimelineViewModel = { static let viewModel: TimelineViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.threadsEnabled = true userSettings.app.threadsEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let roomProxy = JoinedRoomProxyMock(.init()) let roomProxy = JoinedRoomProxyMock(.init())
@@ -446,7 +446,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
static let viewModelWithPins: TimelineViewModel = { static let viewModelWithPins: TimelineViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.threadsEnabled = true userSettings.app.threadsEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let roomProxy = JoinedRoomProxyMock(.init(name: "Preview Room", pinnedEventIDs: ["pinned"])) let roomProxy = JoinedRoomProxyMock(.init(name: "Preview Room", pinnedEventIDs: ["pinned"]))
@@ -467,7 +467,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
static let selectingViewModel: TimelineViewModel = { static let selectingViewModel: TimelineViewModel = {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.messageMultiSelectEnabled = true userSettings.app.messageMultiSelectEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init()), let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init()),
@@ -67,12 +67,12 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
try? FileManager.default.removeItem(at: Self.selectedToneLocation) try? FileManager.default.removeItem(at: Self.selectedToneLocation)
let toneLocation = Self.toneLocation(for: alertTone) let toneLocation = Self.toneLocation(for: alertTone)
try FileManager.default.copyItem(at: toneLocation, to: Self.selectedToneLocation) try FileManager.default.copyItem(at: toneLocation, to: Self.selectedToneLocation)
userSettings.selectedNotificationTone = alertTone userSettings.app.selectedNotificationTone = alertTone
return Self.selectedToneLocation return Self.selectedToneLocation
} catch { } catch {
if (try? Self.selectedToneLocation.checkResourceIsReachable()) != true { if (try? Self.selectedToneLocation.checkResourceIsReachable()) != true {
// make sure the selected tone is reset if there's no custom tone present // make sure the selected tone is reset if there's no custom tone present
userSettings.selectedNotificationTone = nil userSettings.app.selectedNotificationTone = nil
} }
throw error throw error
} }
@@ -235,7 +235,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
switch await userSession(for: client) { switch await userSession(for: client) {
case .success(let userSession): case .success(let userSession):
// Since the QR code login flow includes verification. // Since the QR code login flow includes verification.
userSession.userSettings.hasRunIdentityConfirmationOnboarding = true userSession.userSettings.account.hasRunIdentityConfirmationOnboarding = true
progressSubject.send(.signedIn(userSession)) progressSubject.send(.signedIn(userSession))
case .failure(let error): case .failure(let error):
progressSubject.send(completion: .failure(error)) progressSubject.send(completion: .failure(error))
@@ -366,7 +366,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
MXLog.info("Classic app account secrets imported.") MXLog.info("Classic app account secrets imported.")
// Importing the secrets automatically verifies the session. // Importing the secrets automatically verifies the session.
appSettings.userSettings(for: classicAppAccount.userID).hasRunIdentityConfirmationOnboarding = true appSettings.userSettings(for: classicAppAccount.userID).account.hasRunIdentityConfirmationOnboarding = true
} catch { } catch {
MXLog.error("Failed to import secrets for Classic app account: \(error)") MXLog.error("Failed to import secrets for Classic app account: \(error)")
} }
@@ -221,7 +221,7 @@ class ClientProxy: ClientProxyProtocol {
// Route media downloads through a content scanner when one has been configured for the server, // Route media downloads through a content scanner when one has been configured for the server,
// and expose a proxy for the active scanning of content in the timeline. // and expose a proxy for the active scanning of content in the timeline.
if let contentScannerURL = userSettings.contentScannerURL.publisher.value, let client = client as? Client { if let contentScannerURL = userSettings.app.contentScannerURL.publisher.value, let client = client as? Client {
let scanner = ContentScanner(scannerUrl: contentScannerURL.absoluteString) let scanner = ContentScanner(scannerUrl: contentScannerURL.absoluteString)
await client.setContentScanner(contentScanner: scanner) await client.setContentScanner(contentScanner: scanner)
contentScanner = ContentScannerProxy(contentScanner: scanner, client: client) contentScanner = ContentScannerProxy(contentScanner: scanner, client: client)
@@ -516,7 +516,7 @@ class ClientProxy: ClientProxyProtocol {
do { do {
let parameters = CreateRoomParameters(name: nil, let parameters = CreateRoomParameters(name: nil,
topic: nil, topic: nil,
isEncrypted: !userSettings.forceDisableE2EE.publisher.value, isEncrypted: !userSettings.app.forceDisableE2EE.publisher.value,
isDirect: true, isDirect: true,
visibility: .private, visibility: .private,
preset: .trustedPrivateChat, preset: .trustedPrivateChat,
@@ -559,7 +559,7 @@ class ClientProxy: ClientProxyProtocol {
let parameters = CreateRoomParameters(name: name, let parameters = CreateRoomParameters(name: name,
topic: topic, topic: topic,
isEncrypted: !userSettings.forceDisableE2EE.publisher.value && accessType.isEncrypted, isEncrypted: !userSettings.app.forceDisableE2EE.publisher.value && accessType.isEncrypted,
isDirect: false, isDirect: false,
visibility: accessType.visibility, visibility: accessType.visibility,
preset: accessType.preset, preset: accessType.preset,
@@ -1179,7 +1179,7 @@ class ClientProxy: ClientProxyProtocol {
private func reconcileServiceState() async { private func reconcileServiceState() async {
switch desiredServiceState { switch desiredServiceState {
case .running(let offline): case .running(let offline):
if userSettings.clientPausingAndResumingEnabled { if userSettings.app.clientPausingAndResumingEnabled {
do { do {
MXLog.info("Resuming client") MXLog.info("Resuming client")
try await client.resume() try await client.resume()
@@ -1215,7 +1215,7 @@ class ClientProxy: ClientProxyProtocol {
await syncService.stop() await syncService.stop()
MXLog.info("Sync stopped") MXLog.info("Sync stopped")
if userSettings.clientPausingAndResumingEnabled { if userSettings.app.clientPausingAndResumingEnabled {
do { do {
MXLog.info("Pausing client") MXLog.info("Pausing client")
try await client.pause() try await client.pause()
@@ -61,7 +61,7 @@ class EmojiProvider: EmojiProviderProtocol {
return [] return []
} }
return userSettings.frequentlyUsedSystemEmojis.map(\.key) return userSettings.app.frequentlyUsedSystemEmojis.map(\.key)
} }
func markEmojiAsFrequentlyUsed(_ emoji: String) { func markEmojiAsFrequentlyUsed(_ emoji: String) {
@@ -70,9 +70,9 @@ class EmojiProvider: EmojiProviderProtocol {
} }
let frequentlyUsed = if !frequentlyUsedSystemEmojis().contains(emoji) { let frequentlyUsed = if !frequentlyUsedSystemEmojis().contains(emoji) {
userSettings.frequentlyUsedSystemEmojis + [.init(count: 0, key: emoji)] userSettings.app.frequentlyUsedSystemEmojis + [.init(count: 0, key: emoji)]
} else { } else {
userSettings.frequentlyUsedSystemEmojis.map { frequentlyUsedEmoji in userSettings.app.frequentlyUsedSystemEmojis.map { frequentlyUsedEmoji in
if frequentlyUsedEmoji.key == emoji { if frequentlyUsedEmoji.key == emoji {
return FrequentlyUsedEmoji(count: frequentlyUsedEmoji.count + 1, key: emoji) return FrequentlyUsedEmoji(count: frequentlyUsedEmoji.count + 1, key: emoji)
} }
@@ -81,7 +81,7 @@ class EmojiProvider: EmojiProviderProtocol {
} }
} }
userSettings.frequentlyUsedSystemEmojis = frequentlyUsed.sorted { $0.count > $1.count } userSettings.app.frequentlyUsedSystemEmojis = frequentlyUsed.sorted { $0.count > $1.count }
} }
// MARK: - Private // MARK: - Private
@@ -64,7 +64,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// https://developer.apple.com/documentation/corelocation/cllocationmanager/pauseslocationupdatesautomatically // https://developer.apple.com/documentation/corelocation/cllocationmanager/pauseslocationupdatesautomatically
self.locationManager.pausesLocationUpdatesAutomatically = false self.locationManager.pausesLocationUpdatesAutomatically = false
setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: userSettings.liveLocationMinimumDistanceUpdate) setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: userSettings.app.liveLocationMinimumDistanceUpdate)
setupSubscriptions() setupSubscriptions()
} }
@@ -72,8 +72,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
@discardableResult @discardableResult
func requestAlwaysAuthorizationIfPossible() -> Bool { func requestAlwaysAuthorizationIfPossible() -> Bool {
guard !userSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false } guard !userSettings.app.hasRequestedLocationAlwaysLocationAuthorization else { return false }
userSettings.hasRequestedLocationAlwaysLocationAuthorization = true userSettings.app.hasRequestedLocationAlwaysLocationAuthorization = true
locationManager.requestAlwaysAuthorization() locationManager.requestAlwaysAuthorization()
return true return true
} }
@@ -81,7 +81,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
func startLiveLocation(roomID: String, duration: Duration) async -> Result<Void, LiveLocationManagerError> { func startLiveLocation(roomID: String, duration: Duration) async -> Result<Void, LiveLocationManagerError> {
// Stop any existing session for this room first // Stop any existing session for this room first
var didAlreadyStopLocalSession = false var didAlreadyStopLocalSession = false
if userSettings.liveLocationSharingSessionsByRoomID[roomID] != nil if userSettings.app.liveLocationSharingSessionsByRoomID[roomID] != nil
|| startingLiveLocationSharingSessionsByRoomID[roomID] != nil { || startingLiveLocationSharingSessionsByRoomID[roomID] != nil {
await stopLiveLocation(roomID: roomID) await stopLiveLocation(roomID: roomID)
didAlreadyStopLocalSession = true didAlreadyStopLocalSession = true
@@ -114,7 +114,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
var roomProxy: JoinedRoomProxyProtocol? var roomProxy: JoinedRoomProxyProtocol?
let cachedRoomProxy = activeRoomProxies[roomID] let cachedRoomProxy = activeRoomProxies[roomID]
startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: roomID) startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: roomID)
userSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: roomID) userSettings.app.liveLocationSharingSessionsByRoomID.removeValue(forKey: roomID)
if let cachedRoomProxy { if let cachedRoomProxy {
roomProxy = cachedRoomProxy roomProxy = cachedRoomProxy
@@ -137,7 +137,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// If the system resets authorization to notDetermined (e.g. after app reinstall or // If the system resets authorization to notDetermined (e.g. after app reinstall or
// settings reset), clear the flag so we can request again. // settings reset), clear the flag so we can request again.
if manager.authorizationStatus == .notDetermined { if manager.authorizationStatus == .notDetermined {
userSettings.hasRequestedLocationAlwaysLocationAuthorization = false userSettings.app.hasRequestedLocationAlwaysLocationAuthorization = false
} }
// If authorization was revoked, stop all active sessions. // If authorization was revoked, stop all active sessions.
@@ -146,7 +146,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
// Accuracy authorization may have changed, reapply new accuracy settings. // Accuracy authorization may have changed, reapply new accuracy settings.
setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: userSettings.liveLocationMinimumDistanceUpdate) setupMinimumDistanceUpdatesAndAccuracy(minimumDistance: userSettings.app.liveLocationMinimumDistanceUpdate)
authorizationStatusSubject.send(manager.authorizationStatus) authorizationStatusSubject.send(manager.authorizationStatus)
} }
@@ -183,7 +183,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
.store(in: &cancellables) .store(in: &cancellables)
userSettings.liveLocationSharingSessionsByRoomIDPublisher userSettings.app.liveLocationSharingSessionsByRoomIDPublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] sessions in .sink { [weak self] sessions in
guard let self else { return } guard let self else { return }
@@ -197,7 +197,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
.store(in: &cancellables) .store(in: &cancellables)
userSettings.liveLocationMinimumDistanceUpdatePublisher userSettings.app.liveLocationMinimumDistanceUpdatePublisher
.removeDuplicates() .removeDuplicates()
.debounce(for: .seconds(1), scheduler: DispatchQueue.main) .debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [weak self] newValue in .sink { [weak self] newValue in
@@ -210,7 +210,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// A new beaconInfo has been received in a room with existing active session. // A new beaconInfo has been received in a room with existing active session.
// This is either a new start or a new stop from a different device, so we // This is either a new start or a new stop from a different device, so we
// should remove the session from the current local one. // should remove the session from the current local one.
userSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID) userSettings.app.liveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID)
// Instead if we receive a new isLiveUpdate // Instead if we receive a new isLiveUpdate
guard update.isLive else { return } guard update.isLive else { return }
@@ -224,7 +224,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// This means the server has echoed the start of the session and we can safely promote it // This means the server has echoed the start of the session and we can safely promote it
// to a started session and start sending live locations. // to a started session and start sending live locations.
startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID) startingLiveLocationSharingSessionsByRoomID.removeValue(forKey: update.roomID)
userSettings.liveLocationSharingSessionsByRoomID[update.roomID] = session userSettings.app.liveLocationSharingSessionsByRoomID[update.roomID] = session
if isUpdatingLocation, let lastLocation { if isUpdatingLocation, let lastLocation {
locationUpdateSubject.send(lastLocation) locationUpdateSubject.send(lastLocation)
@@ -281,7 +281,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
/// discarding any intermediate updates while always keeping the last one. /// discarding any intermediate updates while always keeping the last one.
private func processLocationUpdateIfNeeded() { private func processLocationUpdateIfNeeded() {
guard !isProcessingLocationUpdate, let location = latestPendingLocation else { return } guard !isProcessingLocationUpdate, let location = latestPendingLocation else { return }
guard !userSettings.liveLocationSharingSessionsByRoomID.isEmpty else { return } guard !userSettings.app.liveLocationSharingSessionsByRoomID.isEmpty else { return }
latestPendingLocation = nil latestPendingLocation = nil
isProcessingLocationUpdate = true isProcessingLocationUpdate = true
@@ -306,7 +306,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
private func sendLocationToActiveRooms(_ coordinate: CLLocationCoordinate2D) async { private func sendLocationToActiveRooms(_ coordinate: CLLocationCoordinate2D) async {
let sessions = userSettings.liveLocationSharingSessionsByRoomID let sessions = userSettings.app.liveLocationSharingSessionsByRoomID
let geoURI = GeoURI(coordinate: coordinate, uncertainty: nil) let geoURI = GeoURI(coordinate: coordinate, uncertainty: nil)
for (roomID, session) in sessions { for (roomID, session) in sessions {
@@ -351,7 +351,7 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
} }
private func stopAllSessions() { private func stopAllSessions() {
let roomIDs = Array(Set(userSettings.liveLocationSharingSessionsByRoomID.keys) let roomIDs = Array(Set(userSettings.app.liveLocationSharingSessionsByRoomID.keys)
.union(startingLiveLocationSharingSessionsByRoomID.keys)) .union(startingLiveLocationSharingSessionsByRoomID.keys))
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -171,7 +171,7 @@ struct MediaUploadingPreprocessor {
try stripLocationFromImage(at: url, type: type) try stripLocationFromImage(at: url, type: type)
var mimeType = mimeType var mimeType = mimeType
if userSettings.optimizeMediaUploads, !type.conforms(to: .gif) { if userSettings.app.optimizeMediaUploads, !type.conforms(to: .gif) {
let outputType = type.conforms(to: .png) ? UTType.png : .jpeg let outputType = type.conforms(to: .png) ? UTType.png : .jpeg
mimeType = outputType.preferredMIMEType ?? "application/octet-stream" mimeType = outputType.preferredMIMEType ?? "application/octet-stream"
try resizeImage(at: url, maxPixelSize: Constants.optimizedMaxPixelSize, destination: url, type: outputType) try resizeImage(at: url, maxPixelSize: Constants.optimizedMaxPixelSize, destination: url, type: outputType)
@@ -439,7 +439,7 @@ struct MediaUploadingPreprocessor {
/// - Returns: the URL for the resulting video and its media info as a `VideoProcessingResult` /// - Returns: the URL for the resulting video and its media info as a `VideoProcessingResult`
private func convertVideoToMP4(_ url: URL, targetFileSize: UInt) async throws(MediaUploadingPreprocessorError) -> VideoProcessingInfo { private func convertVideoToMP4(_ url: URL, targetFileSize: UInt) async throws(MediaUploadingPreprocessorError) -> VideoProcessingInfo {
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
let presetName = userSettings.optimizeMediaUploads ? AVAssetExportPreset1280x720 : AVAssetExportPreset1920x1080 let presetName = userSettings.app.optimizeMediaUploads ? AVAssetExportPreset1280x720 : AVAssetExportPreset1920x1080
guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else { guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else {
throw .failedConvertingVideo throw .failedConvertingVideo
@@ -41,7 +41,7 @@ final class PresenceService {
} }
private var desiredPresence: ClientProxyPresence { private var desiredPresence: ClientProxyPresence {
switch (isForegroundActive, userSettings.sharePresence) { switch (isForegroundActive, userSettings.app.sharePresence) {
case (true, true): case (true, true):
.online .online
case (true, false): case (true, false):
@@ -68,7 +68,7 @@ final class PresenceService {
} }
private func observeSharePresence() { private func observeSharePresence() {
userSettings.sharePresencePublisher userSettings.app.sharePresencePublisher
.removeDuplicates() .removeDuplicates()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.reportCurrentState() self?.reportCurrentState()
@@ -83,7 +83,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
infoSubject = try await .init(RoomInfoProxy(roomInfo: room.roomInfo())) infoSubject = try await .init(RoomInfoProxy(roomInfo: room.roomInfo()))
let openRoomSpan = analyticsService.signpost.addSpan(.timelineLoad, toTransaction: .openRoom) let openRoomSpan = analyticsService.signpost.addSpan(.timelineLoad, toTransaction: .openRoom)
timeline = try await TimelineProxy(timeline: room.timelineWithConfiguration(configuration: .init(focus: .live(hideThreadedEvents: userSettings.threadsEnabled), timeline = try await TimelineProxy(timeline: room.timelineWithConfiguration(configuration: .init(focus: .live(hideThreadedEvents: userSettings.app.threadsEnabled),
filter: .eventFilter(filter: Self.excludedEventsFilter), filter: .eventFilter(filter: Self.excludedEventsFilter),
internalIdPrefix: nil, internalIdPrefix: nil,
dateDividerMode: .daily, dateDividerMode: .daily,
@@ -150,7 +150,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
let openRoomSpan = analyticsService.signpost.addSpan(.timelineLoad, toTransaction: .notificationToMessage) let openRoomSpan = analyticsService.signpost.addSpan(.timelineLoad, toTransaction: .notificationToMessage)
let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .event(eventId: eventID, let sdkTimeline = try await room.timelineWithConfiguration(configuration: .init(focus: .event(eventId: eventID,
numContextEvents: numberOfEvents, numContextEvents: numberOfEvents,
threadMode: .automatic(hideThreadedEvents: userSettings.threadsEnabled)), threadMode: .automatic(hideThreadedEvents: userSettings.app.threadsEnabled)),
filter: .all, filter: .all,
internalIdPrefix: UUID().uuidString, internalIdPrefix: UUID().uuidString,
dateDividerMode: .daily, dateDividerMode: .daily,
@@ -165,7 +165,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
rustFilters.append(.identifiers(identifiers: Array(roomIDs))) rustFilters.append(.identifiers(identifiers: Array(roomIDs)))
if !filters.contains(.lowPriority), userSettings.lowPriorityFilterEnabled { if !filters.contains(.lowPriority), userSettings.app.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority) rustFilters.append(.nonLowPriority)
} }
@@ -173,7 +173,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
case let .all(filters): case let .all(filters):
var rustFilters = filters.map(\.rustFilter) + baseFilter var rustFilters = filters.map(\.rustFilter) + baseFilter
if !filters.contains(.lowPriority), userSettings.lowPriorityFilterEnabled { if !filters.contains(.lowPriority), userSettings.app.lowPriorityFilterEnabled {
rustFilters.append(.nonLowPriority) rustFilters.append(.nonLowPriority)
} }
@@ -184,7 +184,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
// MARK: - Private // MARK: - Private
private func nameFilter(for query: String) -> [RoomListEntriesDynamicFilterKind] { private func nameFilter(for query: String) -> [RoomListEntriesDynamicFilterKind] {
userSettings.fuzzyRoomListSearchEnabled ? [.fuzzyMatchRoomName(pattern: query)] : [.normalizedMatchRoomName(pattern: query)] userSettings.app.fuzzyRoomListSearchEnabled ? [.fuzzyMatchRoomName(pattern: query)] : [.normalizedMatchRoomName(pattern: query)]
} }
private func setupVisibleRangeObservers() { private func setupVisibleRangeObservers() {
@@ -128,7 +128,7 @@ class TimelineController: TimelineControllerProtocol {
} }
func sendReadReceipt(for itemID: TimelineItemIdentifier) async { func sendReadReceipt(for itemID: TimelineItemIdentifier) async {
let receiptType: MatrixRustSDK.ReceiptType = userSettings.sharePresence ? .read : .readPrivate let receiptType: MatrixRustSDK.ReceiptType = userSettings.app.sharePresence ? .read : .readPrivate
guard let eventID = itemID.eventID else { guard let eventID = itemID.eventID else {
return return
@@ -603,9 +603,9 @@ class MockScreen: Identifiable {
case .userSessionScreen, .userSessionScreenReply, .userSessionSpacesFlow: case .userSessionScreen, .userSessionScreenReply, .userSessionSpacesFlow:
let userID = "@mock:client.com" let userID = "@mock:client.com"
let userSettings = appSettings.userSettings(for: userID) let userSettings = appSettings.userSettings(for: userID)
userSettings.hasRunIdentityConfirmationOnboarding = true userSettings.account.hasRunIdentityConfirmationOnboarding = true
userSettings.hasRunNotificationPermissionsOnboarding = true userSettings.app.hasRunNotificationPermissionsOnboarding = true
userSettings.analyticsConsentState = .optedOut userSettings.app.analyticsConsentState = .optedOut
let roomSummaries: [RoomSummary] = if id == .userSessionSpacesFlow { let roomSummaries: [RoomSummary] = if id == .userSessionSpacesFlow {
[[RoomSummary].mockSpaceInvites[0]] + .mockRooms [[RoomSummary].mockSpaceInvites[0]] + .mockRooms
@@ -828,9 +828,9 @@ class MockScreen: Identifiable {
case .autoUpdatingTimeline: case .autoUpdatingTimeline:
let userID = "@mock:client.com" let userID = "@mock:client.com"
let userSettings = appSettings.userSettings(for: userID) let userSettings = appSettings.userSettings(for: userID)
userSettings.hasRunIdentityConfirmationOnboarding = true userSettings.account.hasRunIdentityConfirmationOnboarding = true
userSettings.hasRunNotificationPermissionsOnboarding = true userSettings.app.hasRunNotificationPermissionsOnboarding = true
userSettings.analyticsConsentState = .optedOut userSettings.app.analyticsConsentState = .optedOut
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: false)) let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator(hideBrandChrome: false))
navigationRootCoordinator.setRootCoordinator(navigationSplitCoordinator) navigationRootCoordinator.setRootCoordinator(navigationSplitCoordinator)
@@ -18,7 +18,7 @@ final class BugReportServiceTests {
init() throws { init() throws {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
userSettings.bugReportRageshakeURL.reset() userSettings.app.bugReportRageshakeURL.reset()
let bugReportServiceMock = BugReportServiceMock() let bugReportServiceMock = BugReportServiceMock()
bugReportServiceMock.lastCrashEventIDSubject = .init(nil) bugReportServiceMock.lastCrashEventIDSubject = .init(nil)
@@ -27,7 +27,7 @@ final class BugReportServiceTests {
} }
deinit { deinit {
userSettings.bugReportRageshakeURL.reset() userSettings.app.bugReportRageshakeURL.reset()
} }
@Test @Test
@@ -104,22 +104,22 @@ final class BugReportServiceTests {
@Test @Test
@MainActor @MainActor
func configurations() async throws { func configurations() async throws {
guard case let .url(initialURL) = userSettings.bugReportRageshakeURL.publisher.value else { guard case let .url(initialURL) = userSettings.app.bugReportRageshakeURL.publisher.value else {
Issue.record("Unexpected initial configuration.") Issue.record("Unexpected initial configuration.")
return return
} }
let service = BugReportService(rageshakeURLPublisher: userSettings.bugReportRageshakeURL.publisher, let service = BugReportService(rageshakeURLPublisher: userSettings.app.bugReportRageshakeURL.publisher,
applicationID: "mock_app_id", applicationID: "mock_app_id",
sdkGitSHA: "1234", sdkGitSHA: "1234",
session: .mock, session: .mock,
appHooks: AppHooks()) appHooks: AppHooks())
#expect(service.isEnabled) #expect(service.isEnabled)
userSettings.bugReportRageshakeURL.applyRemoteValue(.disabled) userSettings.app.bugReportRageshakeURL.applyRemoteValue(.disabled)
#expect(!service.isEnabled) #expect(!service.isEnabled)
userSettings.bugReportRageshakeURL.applyRemoteValue(.url("https://bugs.server.net/submit")) userSettings.app.bugReportRageshakeURL.applyRemoteValue(.url("https://bugs.server.net/submit"))
#expect(service.isEnabled) #expect(service.isEnabled)
let bugReport = BugReport(userID: "@mock:client.com", let bugReport = BugReport(userID: "@mock:client.com",
@@ -136,7 +136,7 @@ final class BugReportServiceTests {
#expect(customConfigurationResponse.reportURL == "https://bugs.server.net/123") #expect(customConfigurationResponse.reportURL == "https://bugs.server.net/123")
userSettings.bugReportRageshakeURL.reset() userSettings.app.bugReportRageshakeURL.reset()
#expect(service.isEnabled) #expect(service.isEnabled)
let defaultConfigurationResponse = try await service.submitBugReport(bugReport, progressListener: progressSubject).get() let defaultConfigurationResponse = try await service.submitBugReport(bugReport, progressListener: progressSubject).get()
@@ -85,7 +85,7 @@ final class CreateRoomScreenViewModelTests {
spaceServiceConfiguration: .init(spaceRoomLists: ["1": .init()]))) spaceServiceConfiguration: .init(spaceRoomLists: ["1": .init()])))
clientProxy.roomForIdentifierClosure = { roomID in .joined(JoinedRoomProxyMock(.init(id: roomID))) } clientProxy.roomForIdentifierClosure = { roomID in .joined(JoinedRoomProxyMock(.init(id: roomID))) }
userSession = UserSessionMock(.init(clientProxy: clientProxy)) userSession = UserSessionMock(.init(clientProxy: clientProxy))
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
let viewModel = CreateRoomScreenViewModel(isSpace: true, let viewModel = CreateRoomScreenViewModel(isSpace: true,
spaceSelectionMode: .none, spaceSelectionMode: .none,
shouldShowCancelButton: false, shouldShowCancelButton: false,
@@ -336,7 +336,7 @@ final class CreateRoomScreenViewModelTests {
clientProxy.spaceService = spaceService clientProxy.spaceService = spaceService
clientProxy.roomForIdentifierClosure = { roomID in .joined(JoinedRoomProxyMock(.init(id: roomID))) } clientProxy.roomForIdentifierClosure = { roomID in .joined(JoinedRoomProxyMock(.init(id: roomID))) }
userSession = UserSessionMock(.init(clientProxy: clientProxy)) userSession = UserSessionMock(.init(clientProxy: clientProxy))
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
let viewModel = CreateRoomScreenViewModel(isSpace: isSpace, let viewModel = CreateRoomScreenViewModel(isSpace: isSpace,
spaceSelectionMode: spacesSelectionMode, spaceSelectionMode: spacesSelectionMode,
shouldShowCancelButton: false, shouldShowCancelButton: false,
@@ -275,7 +275,7 @@ final class HomeScreenViewModelTests {
room.roomID == invites[0].roomID && room.badges.isDotShown == false room.roomID == invites[0].roomID && room.badges.isDotShown == false
} }
} }
userSettings.seenInvites = Set(invites.compactMap(\.roomID)) userSettings.app.seenInvites = Set(invites.compactMap(\.roomID))
try await deferred.fulfill() try await deferred.fulfill()
invites = context.viewState.rooms.invites invites = context.viewState.rooms.invites
@@ -289,14 +289,14 @@ final class HomeScreenViewModelTests {
setupViewModel(invites: .rooms) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
userSettings.seenInvites = Set(invitedRoomIDs) userSettings.app.seenInvites = Set(invitedRoomIDs)
#expect(invitedRoomIDs.count == 2) #expect(invitedRoomIDs.count == 2)
let deferred = deferFulfillment(viewModel.actions) { $0 == .presentRoom(roomIdentifier: invitedRoomIDs[0]) } let deferred = deferFulfillment(viewModel.actions) { $0 == .presentRoom(roomIdentifier: invitedRoomIDs[0]) }
context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0])) context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0]))
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.seenInvites == [invitedRoomIDs[1]]) #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]])
#expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") #expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.")
} }
@@ -305,7 +305,7 @@ final class HomeScreenViewModelTests {
setupViewModel(invites: .spaces) setupViewModel(invites: .spaces)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
userSettings.seenInvites = Set(invitedRoomIDs) userSettings.app.seenInvites = Set(invitedRoomIDs)
#expect(invitedRoomIDs.count == 2) #expect(invitedRoomIDs.count == 2)
let deferred = deferFulfillment(viewModel.actions) { let deferred = deferFulfillment(viewModel.actions) {
@@ -314,7 +314,7 @@ final class HomeScreenViewModelTests {
context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0])) context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0]))
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.seenInvites == [invitedRoomIDs[1]]) #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]])
#expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") #expect(!notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.")
} }
@@ -322,7 +322,7 @@ final class HomeScreenViewModelTests {
func declineInvite() async throws { func declineInvite() async throws {
setupViewModel(invites: .rooms) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
userSettings.seenInvites = Set(invitedRoomIDs) userSettings.app.seenInvites = Set(invitedRoomIDs)
#expect(invitedRoomIDs.count == 2) #expect(invitedRoomIDs.count == 2)
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
@@ -345,7 +345,7 @@ final class HomeScreenViewModelTests {
try await Task.sleep(for: .milliseconds(100)) try await Task.sleep(for: .milliseconds(100))
#expect(rejectCalled) #expect(rejectCalled)
#expect(userSettings.seenInvites == [invitedRoomIDs[1]]) #expect(userSettings.app.seenInvites == [invitedRoomIDs[1]])
#expect(notificationManager.removeDeliveredMessageNotificationsForCalled) #expect(notificationManager.removeDeliveredMessageNotificationsForCalled)
#expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == [invitedRoomIDs[0]]) #expect(notificationManager.removeDeliveredMessageNotificationsForReceivedInvocations == [invitedRoomIDs[0]])
} }
@@ -354,7 +354,7 @@ final class HomeScreenViewModelTests {
func declineAndBlockInvite() async throws { func declineAndBlockInvite() async throws {
setupViewModel(invites: .rooms) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
userSettings.seenInvites = Set(invitedRoomIDs) userSettings.app.seenInvites = Set(invitedRoomIDs)
#expect(invitedRoomIDs.count == 2) #expect(invitedRoomIDs.count == 2)
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil } let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
@@ -368,7 +368,7 @@ final class HomeScreenViewModelTests {
@Test @Test
func newSoundBanner() { func newSoundBanner() {
userSettings.hasSeenNewSoundBanner = false userSettings.app.hasSeenNewSoundBanner = false
setupViewModel() setupViewModel()
#expect(context.viewState.shouldShowBanner) #expect(context.viewState.shouldShowBanner)
@@ -377,26 +377,26 @@ final class HomeScreenViewModelTests {
context.send(viewAction: .dismissNewSoundBanner) context.send(viewAction: .dismissNewSoundBanner)
#expect(!context.viewState.shouldShowBanner) #expect(!context.viewState.shouldShowBanner)
#expect(!context.viewState.shouldShowNewSoundBanner) #expect(!context.viewState.shouldShowNewSoundBanner)
#expect(userSettings.hasSeenNewSoundBanner) #expect(userSettings.app.hasSeenNewSoundBanner)
} }
@Test @Test
func multiAccountAnnouncement() async throws { func multiAccountAnnouncement() async throws {
userSettings.multiAccountEnabled = true userSettings.app.multiAccountEnabled = true
setupViewModel() setupViewModel()
// Arming it after the view model exists (Developer options) doesn't present it on its own. // Arming it after the view model exists (Developer options) doesn't present it on its own.
userSettings.hasSeenMultiAccountAnnouncement = false userSettings.app.hasSeenMultiAccountAnnouncement = false
let deferredFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement } let deferredFailure = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
try await deferredFailure.fulfill() try await deferredFailure.fulfill()
let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement } let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared) context.send(viewAction: .screenAppeared)
try await deferred.fulfill() try await deferred.fulfill()
#expect(!userSettings.hasSeenMultiAccountAnnouncement) #expect(!userSettings.app.hasSeenMultiAccountAnnouncement)
context.send(viewAction: .multiAccountAnnouncementAppeared) context.send(viewAction: .multiAccountAnnouncementAppeared)
#expect(userSettings.hasSeenMultiAccountAnnouncement) #expect(userSettings.app.hasSeenMultiAccountAnnouncement)
#expect(context.viewState.bindings.isPresentingMultiAccountAnnouncement) #expect(context.viewState.bindings.isPresentingMultiAccountAnnouncement)
// A swipe down dismisses it without any of the announcement's actions. // A swipe down dismisses it without any of the announcement's actions.
@@ -408,8 +408,8 @@ final class HomeScreenViewModelTests {
@Test(arguments: [HomeScreenViewAction.dismissMultiAccountAnnouncement, .addAccount]) @Test(arguments: [HomeScreenViewAction.dismissMultiAccountAnnouncement, .addAccount])
func multiAccountAnnouncementActionsDismissIt(_ action: HomeScreenViewAction) async throws { func multiAccountAnnouncementActionsDismissIt(_ action: HomeScreenViewAction) async throws {
userSettings.multiAccountEnabled = true userSettings.app.multiAccountEnabled = true
userSettings.hasSeenMultiAccountAnnouncement = false userSettings.app.hasSeenMultiAccountAnnouncement = false
setupViewModel() setupViewModel()
let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement } let deferred = deferFulfillment(context.$viewState) { $0.bindings.isPresentingMultiAccountAnnouncement }
@@ -422,8 +422,8 @@ final class HomeScreenViewModelTests {
@Test(arguments: MultiAccountAnnouncementHiddenCase.allCases) @Test(arguments: MultiAccountAnnouncementHiddenCase.allCases)
func multiAccountAnnouncementHidden(_ hiddenCase: MultiAccountAnnouncementHiddenCase) async throws { func multiAccountAnnouncementHidden(_ hiddenCase: MultiAccountAnnouncementHiddenCase) async throws {
userSettings.multiAccountEnabled = hiddenCase != .flagOff userSettings.app.multiAccountEnabled = hiddenCase != .flagOff
userSettings.hasSeenMultiAccountAnnouncement = hiddenCase == .alreadySeen userSettings.app.hasSeenMultiAccountAnnouncement = hiddenCase == .alreadySeen
let verificationState: SessionVerificationState = switch hiddenCase { let verificationState: SessionVerificationState = switch hiddenCase {
case .unverified: .unverified case .unverified: .unverified
case .unknownVerificationState: .unknown case .unknownVerificationState: .unknown
@@ -435,13 +435,13 @@ final class HomeScreenViewModelTests {
let deferred = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement } let deferred = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared) context.send(viewAction: .screenAppeared)
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.hasSeenMultiAccountAnnouncement == (hiddenCase == .alreadySeen)) #expect(userSettings.app.hasSeenMultiAccountAnnouncement == (hiddenCase == .alreadySeen))
} }
@Test @Test
func multiAccountAnnouncementPresentsOnTheNextAppearanceOnceVerified() async throws { func multiAccountAnnouncementPresentsOnTheNextAppearanceOnceVerified() async throws {
userSettings.multiAccountEnabled = true userSettings.app.multiAccountEnabled = true
userSettings.hasSeenMultiAccountAnnouncement = false userSettings.app.hasSeenMultiAccountAnnouncement = false
let securityStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .unverified, recoveryState: .enabled)) let securityStateSubject = CurrentValueSubject<SessionSecurityState, Never>(.init(verificationState: .unverified, recoveryState: .enabled))
setupViewModel(securityStatePublisher: securityStateSubject.asCurrentValuePublisher()) setupViewModel(securityStatePublisher: securityStateSubject.asCurrentValuePublisher())
@@ -45,12 +45,12 @@ final class JoinRoomScreenViewModelTests {
@Test @Test
func interaction() async throws { func interaction() async throws {
#expect(userSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") #expect(userSettings.app.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel() setupViewModel()
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .joinable }.fulfill() try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .joinable }.fulfill()
#expect(userSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") #expect(userSettings.app.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) } let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) }
context.send(viewAction: .join) context.send(viewAction: .join)
@@ -59,28 +59,28 @@ final class JoinRoomScreenViewModelTests {
@Test @Test
func acceptInviteInteraction() async throws { func acceptInviteInteraction() async throws {
#expect(userSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") #expect(userSettings.app.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .invited) setupViewModel(mode: .invited)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill()
#expect(userSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.") #expect(userSettings.app.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.")
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) } let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined(.roomID("1")) }
context.send(viewAction: .acceptInvite) context.send(viewAction: .acceptInvite)
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.") #expect(userSettings.app.seenInvites.isEmpty, "The after accepting an invite the invite should be forgotten in case the user leaves.")
} }
@Test @Test
func declineInviteInteraction() async throws { func declineInviteInteraction() async throws {
#expect(userSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") #expect(userSettings.app.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .invited) setupViewModel(mode: .invited)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill() try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .invited(isDM: false) }.fulfill()
#expect(userSettings.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.") #expect(userSettings.app.seenInvites == ["1"], "The invited room's ID should be registered as a seen invite.")
context.send(viewAction: .declineInvite) context.send(viewAction: .declineInvite)
@@ -89,17 +89,17 @@ final class JoinRoomScreenViewModelTests {
context.alertInfo?.secondaryButton?.action?() context.alertInfo?.secondaryButton?.action?()
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.") #expect(userSettings.app.seenInvites.isEmpty, "The after declining an invite the invite should be forgotten in case another invite is received.")
} }
@Test @Test
func knockedState() async throws { func knockedState() async throws {
#expect(userSettings.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.") #expect(userSettings.app.seenInvites.isEmpty, "There shouldn't be any seen invites before running the tests.")
setupViewModel(mode: .knocked) setupViewModel(mode: .knocked)
try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .knocked }.fulfill() try await deferFulfillment(viewModel.context.$viewState) { $0.mode == .knocked }.fulfill()
#expect(userSettings.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.") #expect(userSettings.app.seenInvites.isEmpty, "Only an invited room should register the room ID as a seen invite.")
} }
@Test @Test
@@ -41,11 +41,11 @@ final class LiveLocationManagerTests {
try result.get() try result.get()
#expect(callOrder == ["stop", "start"]) #expect(callOrder == ["stop", "start"])
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil)
try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org")
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil)
#expect(locationManagerMock.startUpdatingLocationCalled) #expect(locationManagerMock.startUpdatingLocationCalled)
} }
@@ -54,7 +54,7 @@ final class LiveLocationManagerTests {
setUp() setUp()
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$old_event:matrix.org", expirationDate: Date().addingTimeInterval(300)) userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$old_event:matrix.org", expirationDate: Date().addingTimeInterval(300))
var callOrder: [String] = [] var callOrder: [String] = []
roomProxy.stopLiveLocationShareClosure = { roomProxy.stopLiveLocationShareClosure = {
@@ -73,7 +73,7 @@ final class LiveLocationManagerTests {
try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org")
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil)
} }
@Test @Test
@@ -82,12 +82,12 @@ final class LiveLocationManagerTests {
let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
userSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) userSettings.app.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300))
_ = await manager.startLiveLocation(roomID: "!room1:matrix.org", duration: .seconds(300)) _ = await manager.startLiveLocation(roomID: "!room1:matrix.org", duration: .seconds(300))
#expect(roomProxy.stopLiveLocationShareCalled) #expect(roomProxy.stopLiveLocationShareCalled)
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil)
} }
@Test @Test
@@ -98,7 +98,7 @@ final class LiveLocationManagerTests {
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300))
#expect(throws: LiveLocationManagerError.roomNotJoined) { try result.get() } #expect(throws: LiveLocationManagerError.roomNotJoined) { try result.get() }
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil)
} }
@Test @Test
@@ -111,7 +111,7 @@ final class LiveLocationManagerTests {
let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)) let result = await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300))
#expect(throws: LiveLocationManagerError.startFailed) { try result.get() } #expect(throws: LiveLocationManagerError.startFailed) { try result.get() }
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil)
} }
@Test @Test
@@ -127,7 +127,7 @@ final class LiveLocationManagerTests {
try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org")
let storedSession = try #require(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"]) let storedSession = try #require(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"])
let expectedMinTimeout = beforeStart.addingTimeInterval(TimeInterval(duration.seconds)) let expectedMinTimeout = beforeStart.addingTimeInterval(TimeInterval(duration.seconds))
let expectedMaxTimeout = afterStart.addingTimeInterval(TimeInterval(duration.seconds)) let expectedMaxTimeout = afterStart.addingTimeInterval(TimeInterval(duration.seconds))
@@ -142,12 +142,12 @@ final class LiveLocationManagerTests {
setUp() setUp()
let roomProxy = makeRoomProxy(roomID: "!room:matrix.org") let roomProxy = makeRoomProxy(roomID: "!room:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300))
await manager.stopLiveLocation(roomID: "!room:matrix.org") await manager.stopLiveLocation(roomID: "!room:matrix.org")
#expect(roomProxy.stopLiveLocationShareCalled) #expect(roomProxy.stopLiveLocationShareCalled)
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil)
// Setting the timeout date above starts tracking; removing it stops tracking. // Setting the timeout date above starts tracking; removing it stops tracking.
#expect(locationManagerMock.startUpdatingLocationCalled) #expect(locationManagerMock.startUpdatingLocationCalled)
#expect(locationManagerMock.stopUpdatingLocationCalled) #expect(locationManagerMock.stopUpdatingLocationCalled)
@@ -169,13 +169,13 @@ final class LiveLocationManagerTests {
setUp() setUp()
let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org") let roomProxy = makeRoomProxy(roomID: "!room1:matrix.org")
clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) } clientProxy.roomForIdentifierClosure = { _ in .joined(roomProxy) }
userSettings.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) userSettings.app.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300))
userSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300)) userSettings.app.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] = LiveLocationSession(eventID: "$event:matrix.org", expirationDate: Date().addingTimeInterval(300))
await manager.stopLiveLocation(roomID: "!room1:matrix.org") await manager.stopLiveLocation(roomID: "!room1:matrix.org")
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room1:matrix.org"] == nil)
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room2:matrix.org"] != nil)
} }
// MARK: - Beacon info updates // MARK: - Beacon info updates
@@ -188,13 +188,13 @@ final class LiveLocationManagerTests {
try await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)).get() try await manager.startLiveLocation(roomID: "!room:matrix.org", duration: .seconds(300)).get()
try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org") try await simulateBeaconEcho(roomID: "!room:matrix.org", eventID: "$event:matrix.org")
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] != nil)
let deferred = deferFulfillment(userSettings.liveLocationSharingSessionsByRoomIDPublisher) { $0["!room:matrix.org"] == nil } let deferred = deferFulfillment(userSettings.app.liveLocationSharingSessionsByRoomIDPublisher) { $0["!room:matrix.org"] == nil }
beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: "!room:matrix.org", eventID: "$external_event:matrix.org", isLive: true)) beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: "!room:matrix.org", eventID: "$external_event:matrix.org", isLive: true))
try await deferred.fulfill() try await deferred.fulfill()
#expect(userSettings.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil) #expect(userSettings.app.liveLocationSharingSessionsByRoomID["!room:matrix.org"] == nil)
} }
// MARK: - Reduced accuracy // MARK: - Reduced accuracy
@@ -237,7 +237,7 @@ final class LiveLocationManagerTests {
} }
private func simulateBeaconEcho(roomID: String, eventID: String) async throws { private func simulateBeaconEcho(roomID: String, eventID: String) async throws {
let deferred = deferFulfillment(userSettings.liveLocationSharingSessionsByRoomIDPublisher) { $0[roomID] != nil } let deferred = deferFulfillment(userSettings.app.liveLocationSharingSessionsByRoomIDPublisher) { $0[roomID] != nil }
beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: roomID, eventID: eventID, isLive: true)) beaconInfoSubject.send(LiveLocationOwnInfoUpdate(roomID: roomID, eventID: eventID, isLive: true))
try await deferred.fulfill() try await deferred.fulfill()
} }
@@ -420,7 +420,7 @@ struct LocationSharingScreenViewModelTests {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil), viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: nil, initialLiveLocationShare: nil),
mapURLBuilder: userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
liveLocationManager: LiveLocationManagerMock(.init()), liveLocationManager: LiveLocationManagerMock(.init()),
@@ -546,7 +546,7 @@ struct LocationSharingScreenViewModelTests {
#expect(context.showsUserLocationMode == .hide) #expect(context.showsUserLocationMode == .hide)
// Another device takes over: this device's session is removed and a new own share arrives. // Another device takes over: this device's session is removed and a new own share arrives.
userSettings.liveLocationSharingSessionsByRoomID.removeValue(forKey: Self.roomID) userSettings.app.liveLocationSharingSessionsByRoomID.removeValue(forKey: Self.roomID)
let newOwnShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 48.8, longitude: 2.3) let newOwnShare = makeLiveLocationShare(userID: RoomMemberProxyMock.mockMe.userID, latitude: 48.8, longitude: 2.3)
let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show } let deferred = deferFulfillment(context.observe(\.showsUserLocationMode)) { $0 == .show }
@@ -633,7 +633,7 @@ struct LocationSharingScreenViewModelTests {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true), viewModel = LocationSharingScreenViewModel(interactionMode: .picker(shouldShowLiveLocationOption: true),
mapURLBuilder: userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: JoinedRoomProxyMock(.init(members: members)), roomProxy: JoinedRoomProxyMock(.init(members: members)),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)), timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
liveLocationManager: liveLocationManagerMock, liveLocationManager: liveLocationManagerMock,
@@ -651,7 +651,7 @@ struct LocationSharingScreenViewModelTests {
isSharingLiveLocationFromThisDevice: Bool = false) { isSharingLiveLocationFromThisDevice: Bool = false) {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
if isSharingLiveLocationFromThisDevice { if isSharingLiveLocationFromThisDevice {
userSettings.liveLocationSharingSessionsByRoomID[Self.roomID] = .init(eventID: "$event:matrix.org", expirationDate: .distantFuture) userSettings.app.liveLocationSharingSessionsByRoomID[Self.roomID] = .init(eventID: "$event:matrix.org", expirationDate: .distantFuture)
} }
let liveLocationServiceMock = RoomLiveLocationServiceMock() let liveLocationServiceMock = RoomLiveLocationServiceMock()
@@ -661,7 +661,7 @@ struct LocationSharingScreenViewModelTests {
roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock roomProxyMock.makeLiveLocationServiceReturnValue = liveLocationServiceMock
viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare), viewModel = LocationSharingScreenViewModel(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialShare),
mapURLBuilder: userSettings.mapTilerConfiguration.publisher.value, mapURLBuilder: userSettings.app.mapTilerConfiguration.publisher.value,
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))), timelineController: TimelineControllerMock(.init(timelineProxy: TimelineProxyMock(.init()))),
liveLocationManager: LiveLocationManagerMock(.init()), liveLocationManager: LiveLocationManagerMock(.init()),
@@ -29,7 +29,7 @@ final class MediaUploadPreviewScreenViewModelTests {
init() { init() {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
userSettings.optimizeMediaUploads = false userSettings.app.optimizeMediaUploads = false
userIndicatorController = UserIndicatorControllerMock() userIndicatorController = UserIndicatorControllerMock()
} }
@@ -18,7 +18,7 @@ final class MediaUploadingPreprocessorTests {
init() { init() {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
userSettings.optimizeMediaUploads = false userSettings.app.optimizeMediaUploads = false
mediaUploadingPreprocessor = MediaUploadingPreprocessor(userSettings: userSettings) mediaUploadingPreprocessor = MediaUploadingPreprocessor(userSettings: userSettings)
} }
@@ -80,7 +80,7 @@ final class MediaUploadingPreprocessorTests {
func landscapeMovVideoOptimizedProcessing() async throws { func landscapeMovVideoOptimizedProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset") let url = try #require(Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil), "Failed retrieving test asset")
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
@@ -139,7 +139,7 @@ final class MediaUploadingPreprocessorTests {
func portraitMp4VideoOptimizedProcessing() async throws { func portraitMp4VideoOptimizedProcessing() async throws {
let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset") let url = try #require(Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil), "Failed retrieving test asset")
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else {
@@ -184,7 +184,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 344) #expect(imageInfo.thumbnailInfo?.height == 344)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
@@ -228,7 +228,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 800) #expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
@@ -274,7 +274,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 240) #expect(imageInfo.thumbnailInfo?.height == 240)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
@@ -325,7 +325,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 800) #expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
@@ -377,7 +377,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 472) #expect(imageInfo.thumbnailInfo?.height == 472)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else {
@@ -419,7 +419,7 @@ final class MediaUploadingPreprocessorTests {
#expect(imageInfo.thumbnailInfo?.height == 800) #expect(imageInfo.thumbnailInfo?.height == 800)
// Repeat with optimised media setting // Repeat with optimised media setting
userSettings.optimizeMediaUploads = true userSettings.app.optimizeMediaUploads = true
guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize), guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize),
case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else {
@@ -453,7 +453,7 @@ final class MediaUploadingPreprocessorTests {
fatalError() fatalError()
} }
if userSettings.optimizeMediaUploads { if userSettings.app.optimizeMediaUploads {
// Check that new image has been scaled within the requirements for an optimised image // Check that new image has been scaled within the requirements for an optimised image
#expect(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) #expect(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
#expect(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) #expect(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize)
@@ -42,16 +42,16 @@ struct NotificationSettingsScreenViewModelTests {
@Test @Test
func enableNotifications() { func enableNotifications() {
userSettings.enableNotifications = false userSettings.app.enableNotifications = false
context.send(viewAction: .changedEnableNotifications) context.send(viewAction: .changedEnableNotifications)
#expect(userSettings.enableNotifications) #expect(userSettings.app.enableNotifications)
} }
@Test @Test
func disableNotifications() { func disableNotifications() {
userSettings.enableNotifications = true userSettings.app.enableNotifications = true
context.send(viewAction: .changedEnableNotifications) context.send(viewAction: .changedEnableNotifications)
#expect(!userSettings.enableNotifications) #expect(!userSettings.app.enableNotifications)
} }
@Test @Test
@@ -411,7 +411,7 @@ struct NotificationSettingsScreenViewModelTests {
// Given a tone and a manager that persists the selection to app settings // Given a tone and a manager that persists the selection to app settings
let tone = NotificationTone.createBundledSound(label: "Test", filename: "test.caf") let tone = NotificationTone.createBundledSound(label: "Test", filename: "test.caf")
toneManager.setSelectedToneClosure = { [userSettings] selectedTone in toneManager.setSelectedToneClosure = { [userSettings] selectedTone in
userSettings.selectedNotificationTone = selectedTone userSettings.app.selectedNotificationTone = selectedTone
return NotificationToneManager.libraryLocation return NotificationToneManager.libraryLocation
} }
@@ -419,21 +419,21 @@ struct NotificationSettingsScreenViewModelTests {
context.send(viewAction: .selectAlertTone(tone)) context.send(viewAction: .selectAlertTone(tone))
// Then it is reflected in app settings // Then it is reflected in app settings
#expect(userSettings.selectedNotificationTone == tone) #expect(userSettings.app.selectedNotificationTone == tone)
} }
@Test @Test
func deletingActiveToneResetsSelection() { func deletingActiveToneResetsSelection() {
// Given the active tone is a custom tone // Given the active tone is a custom tone
let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf") let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf")
userSettings.selectedNotificationTone = customTone userSettings.app.selectedNotificationTone = customTone
toneManager.customTonesReturnValue = [customTone] toneManager.customTonesReturnValue = [customTone]
// When that tone is deleted // When that tone is deleted
context.send(viewAction: .deleteCustomAlertTones([customTone])) context.send(viewAction: .deleteCustomAlertTones([customTone]))
// Then the selection is cleared, falling back to the default // Then the selection is cleared, falling back to the default
#expect(userSettings.selectedNotificationTone == nil) #expect(userSettings.app.selectedNotificationTone == nil)
} }
@Test @Test
@@ -441,14 +441,14 @@ struct NotificationSettingsScreenViewModelTests {
// Given a custom tone is active and a different custom tone also exists // Given a custom tone is active and a different custom tone also exists
let activeTone = NotificationTone.createCustomUserSound(filename: "active.caf") let activeTone = NotificationTone.createCustomUserSound(filename: "active.caf")
let otherTone = NotificationTone.createCustomUserSound(filename: "other.caf") let otherTone = NotificationTone.createCustomUserSound(filename: "other.caf")
userSettings.selectedNotificationTone = activeTone userSettings.app.selectedNotificationTone = activeTone
toneManager.customTonesReturnValue = [activeTone, otherTone] toneManager.customTonesReturnValue = [activeTone, otherTone]
// When the non-active tone is deleted // When the non-active tone is deleted
context.send(viewAction: .deleteCustomAlertTones([otherTone])) context.send(viewAction: .deleteCustomAlertTones([otherTone]))
// Then the active selection is unchanged // Then the active selection is unchanged
#expect(userSettings.selectedNotificationTone == activeTone) #expect(userSettings.app.selectedNotificationTone == activeTone)
} }
@Test @Test
@@ -476,7 +476,7 @@ struct NotificationSettingsScreenViewModelTests {
func deleteCustomToneFailurePreservesSelection() { func deleteCustomToneFailurePreservesSelection() {
// Given the active tone is a custom tone and deletion will fail // Given the active tone is a custom tone and deletion will fail
let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf") let customTone = NotificationTone.createCustomUserSound(filename: "custom.caf")
userSettings.selectedNotificationTone = customTone userSettings.app.selectedNotificationTone = customTone
toneManager.deleteCustomToneThrowableError = NSError(domain: "test", code: 1) toneManager.deleteCustomToneThrowableError = NSError(domain: "test", code: 1)
toneManager.customTonesReturnValue = [customTone] toneManager.customTonesReturnValue = [customTone]
@@ -484,7 +484,7 @@ struct NotificationSettingsScreenViewModelTests {
context.send(viewAction: .deleteCustomAlertTones([customTone])) context.send(viewAction: .deleteCustomAlertTones([customTone]))
// Then the selection is unchanged // Then the selection is unchanged
#expect(userSettings.selectedNotificationTone == customTone) #expect(userSettings.app.selectedNotificationTone == customTone)
} }
@Test @Test
+12 -12
View File
@@ -39,7 +39,7 @@ struct PresenceServiceTests {
@Test @Test
func serviceSeededActiveWhileSharingReportsOnline() async throws { func serviceSeededActiveWhileSharingReportsOnline() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let deferred = deferFulfillment(sendsSubject) { $0.presence == .online } let deferred = deferFulfillment(sendsSubject) { $0.presence == .online }
let service = makeService(initialApplicationState: .active) let service = makeService(initialApplicationState: .active)
@@ -53,7 +53,7 @@ struct PresenceServiceTests {
@Test @Test
func serviceSeededBackgroundedWhileSharingReportsUnavailable() async throws { func serviceSeededBackgroundedWhileSharingReportsUnavailable() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable } let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable }
let service = makeService(initialApplicationState: .background) let service = makeService(initialApplicationState: .background)
@@ -66,7 +66,7 @@ struct PresenceServiceTests {
@Test @Test
func serviceSeededActiveWhileNotSharingReportsOffline() async throws { func serviceSeededActiveWhileNotSharingReportsOffline() async throws {
userSettings.sharePresence = false userSettings.app.sharePresence = false
let deferred = deferFulfillment(sendsSubject) { $0.presence == .offline } let deferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
let service = makeService(initialApplicationState: .active) let service = makeService(initialApplicationState: .active)
@@ -79,7 +79,7 @@ struct PresenceServiceTests {
@Test @Test
func becomingActiveNotificationWhileSharingReportsOnlineImmediately() async throws { func becomingActiveNotificationWhileSharingReportsOnlineImmediately() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let service = makeService(initialApplicationState: .inactive) let service = makeService(initialApplicationState: .inactive)
let deferred = deferFulfillment(sendsSubject) { $0.presence == .online } let deferred = deferFulfillment(sendsSubject) { $0.presence == .online }
@@ -93,7 +93,7 @@ struct PresenceServiceTests {
@Test @Test
func resigningActiveNotificationWhileSharingReportsUnavailableImmediately() async throws { func resigningActiveNotificationWhileSharingReportsUnavailableImmediately() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
let service = makeService(initialApplicationState: .active) let service = makeService(initialApplicationState: .active)
_ = try await initialDeferred.fulfill() _ = try await initialDeferred.fulfill()
@@ -109,19 +109,19 @@ struct PresenceServiceTests {
@Test @Test
func togglingSharingWhileActiveDrivesOnlineAndOffline() async throws { func togglingSharingWhileActiveDrivesOnlineAndOffline() async throws {
userSettings.sharePresence = false userSettings.app.sharePresence = false
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline } let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
let service = makeService(initialApplicationState: .active) let service = makeService(initialApplicationState: .active)
_ = try await initialDeferred.fulfill() _ = try await initialDeferred.fulfill()
let onlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let onlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
userSettings.sharePresence = true userSettings.app.sharePresence = true
let online = try await onlineDeferred.fulfill() let online = try await onlineDeferred.fulfill()
#expect(online.presence == .online) #expect(online.presence == .online)
#expect(online.sendImmediately) #expect(online.sendImmediately)
let offlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline } let offlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
userSettings.sharePresence = false userSettings.app.sharePresence = false
let offline = try await offlineDeferred.fulfill() let offline = try await offlineDeferred.fulfill()
#expect(offline.presence == .offline) #expect(offline.presence == .offline)
#expect(offline.sendImmediately) #expect(offline.sendImmediately)
@@ -131,7 +131,7 @@ struct PresenceServiceTests {
@Test @Test
func repeatingTheSameStateSendsOnlyOnce() async throws { func repeatingTheSameStateSendsOnlyOnce() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online } let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
let service = makeService(initialApplicationState: .active) let service = makeService(initialApplicationState: .active)
@@ -148,7 +148,7 @@ struct PresenceServiceTests {
@Test @Test
func failedImmediateSendIsNotRetriedUntilTheDesiredPresenceChanges() async throws { func failedImmediateSendIsNotRetriedUntilTheDesiredPresenceChanges() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in
sendsSubject.send((presence, sendImmediately)) sendsSubject.send((presence, sendImmediately))
return presence == .online ? .failure(.forbiddenAccess) : .success(()) return presence == .online ? .failure(.forbiddenAccess) : .success(())
@@ -173,7 +173,7 @@ struct PresenceServiceTests {
@Test @Test
func newerTransitionWinsWhenAnEarlierSendIsInFlight() async throws { func newerTransitionWinsWhenAnEarlierSendIsInFlight() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let (onlineStartedStream, onlineStartedContinuation) = AsyncStream<Void>.makeStream() let (onlineStartedStream, onlineStartedContinuation) = AsyncStream<Void>.makeStream()
let (resumeOnlineSendStream, resumeOnlineSendContinuation) = AsyncStream<Void>.makeStream() let (resumeOnlineSendStream, resumeOnlineSendContinuation) = AsyncStream<Void>.makeStream()
@@ -206,7 +206,7 @@ struct PresenceServiceTests {
@Test @Test
func deinitCancelsPendingSend() async throws { func deinitCancelsPendingSend() async throws {
userSettings.sharePresence = true userSettings.app.sharePresence = true
let (onlineStartedStream, onlineStartedContinuation) = AsyncStream.makeStream(of: Void.self) let (onlineStartedStream, onlineStartedContinuation) = AsyncStream.makeStream(of: Void.self)
let (onlineFinishedStream, onlineFinishedContinuation) = AsyncStream.makeStream(of: Void.self) let (onlineFinishedStream, onlineFinishedContinuation) = AsyncStream.makeStream(of: Void.self)
@@ -230,7 +230,7 @@ final class RoomFlowCoordinatorTests {
@Test @Test
func threadedEventRoutes() async throws { func threadedEventRoutes() async throws {
userSettings.threadsEnabled = true userSettings.app.threadsEnabled = true
setupRoomFlowCoordinator() setupRoomFlowCoordinator()
// Navigate directly to the threaded event // Navigate directly to the threaded event
@@ -148,12 +148,12 @@ final class RoomListFiltersStateTests {
// MARK: - Helpers // MARK: - Helpers
private func enableLowPriorityFeature() { private func enableLowPriorityFeature() {
userSettings.lowPriorityFilterEnabled = true userSettings.app.lowPriorityFilterEnabled = true
state = RoomListFiltersState(userSettings: userSettings) state = RoomListFiltersState(userSettings: userSettings)
} }
private func enableMentionsFeature() { private func enableMentionsFeature() {
userSettings.mentionsFilterEnabled = true userSettings.app.mentionsFilterEnabled = true
state = RoomListFiltersState(userSettings: userSettings) state = RoomListFiltersState(userSettings: userSettings)
} }
} }
@@ -154,7 +154,7 @@ final class RoomScreenViewModelTests {
@Test @Test
func pinnedEventsBannerThreadedSelection() async throws { func pinnedEventsBannerThreadedSelection() async throws {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.threadsEnabled = true userSettings.app.threadsEnabled = true
let userSession = UserSessionMock(.init(userSettings: userSettings)) let userSession = UserSessionMock(.init(userSettings: userSettings))
let roomProxyMock = JoinedRoomProxyMock(.init()) let roomProxyMock = JoinedRoomProxyMock(.init())
@@ -123,7 +123,7 @@ final class RoomSummaryProviderTests {
private func setup(isLowPriorityFilterEnabled: Bool = false, includesOnlyJoinedRooms: Bool = false, setsRoomList: Bool = true) { private func setup(isLowPriorityFilterEnabled: Bool = false, includesOnlyJoinedRooms: Bool = false, setsRoomList: Bool = true) {
userSettings = UserSettings.mock() userSettings = UserSettings.mock()
userSettings.lowPriorityFilterEnabled = isLowPriorityFilterEnabled userSettings.app.lowPriorityFilterEnabled = isLowPriorityFilterEnabled
let stateEventStringBuilder = RoomStateEventStringBuilder(userID: "@me:matrix.org") let stateEventStringBuilder = RoomStateEventStringBuilder(userID: "@me:matrix.org")
let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder()) let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
@@ -124,44 +124,44 @@ struct SearchScreenViewModelTests {
context.searchQuery = "Second" context.searchQuery = "Second"
context.send(viewAction: .selectRoom(roomID: "2")) context.send(viewAction: .selectRoom(roomID: "2"))
#expect(userSettings.searchBreadcrumbs == [.room(roomID: "2")]) #expect(userSettings.account.searchBreadcrumbs == [.room(roomID: "2")])
context.send(viewAction: .submitQuery) context.send(viewAction: .submitQuery)
#expect(userSettings.searchBreadcrumbs == [.query("Second"), .room(roomID: "2")]) #expect(userSettings.account.searchBreadcrumbs == [.query("Second"), .room(roomID: "2")])
// Selecting the same room again from the empty state only bumps it back to the top. // Selecting the same room again from the empty state only bumps it back to the top.
context.searchQuery = "" context.searchQuery = ""
context.send(viewAction: .selectRoom(roomID: "2")) context.send(viewAction: .selectRoom(roomID: "2"))
#expect(userSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) #expect(userSettings.account.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")])
// Submitting an empty query records nothing. // Submitting an empty query records nothing.
context.send(viewAction: .submitQuery) context.send(viewAction: .submitQuery)
#expect(userSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) #expect(userSettings.account.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")])
} }
@Test @Test
func removingBreadcrumbs() { func removingBreadcrumbs() {
userSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] userSettings.account.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")]
context.send(viewAction: .removeBreadcrumb(.query("Second"))) context.send(viewAction: .removeBreadcrumb(.query("Second")))
#expect(userSettings.searchBreadcrumbs == [.room(roomID: "2")]) #expect(userSettings.account.searchBreadcrumbs == [.room(roomID: "2")])
context.send(viewAction: .removeBreadcrumb(.room(SearchScreenRoom(.mock(id: "2", name: "Second"))))) context.send(viewAction: .removeBreadcrumb(.room(SearchScreenRoom(.mock(id: "2", name: "Second")))))
#expect(userSettings.searchBreadcrumbs.isEmpty) #expect(userSettings.account.searchBreadcrumbs.isEmpty)
} }
@Test @Test
func clearingBreadcrumbs() { func clearingBreadcrumbs() {
userSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] userSettings.account.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")]
context.send(viewAction: .clearBreadcrumbs) context.send(viewAction: .clearBreadcrumbs)
#expect(userSettings.searchBreadcrumbs.isEmpty) #expect(userSettings.account.searchBreadcrumbs.isEmpty)
} }
@Test @Test
@@ -169,7 +169,7 @@ struct SearchScreenViewModelTests {
staticRoomListSubject.send([.mock(id: "2", name: "Second")]) staticRoomListSubject.send([.mock(id: "2", name: "Second")])
let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 2 } let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 2 }
userSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] userSettings.account.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")]
try await deferred.fulfill() try await deferred.fulfill()
#expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"]) #expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second", "room-2"])
@@ -180,7 +180,7 @@ struct SearchScreenViewModelTests {
staticRoomListSubject.send([.mock(id: "2", name: "Second"), .mock(id: "left", name: "Left", isJoined: false)]) staticRoomListSubject.send([.mock(id: "2", name: "Second"), .mock(id: "left", name: "Left", isJoined: false)])
let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { !$0.isEmpty } let deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { !$0.isEmpty }
userSettings.searchBreadcrumbs = [.room(roomID: "left"), .room(roomID: "2")] userSettings.account.searchBreadcrumbs = [.room(roomID: "left"), .room(roomID: "2")]
try await deferred.fulfill() try await deferred.fulfill()
#expect(context.viewState.breadcrumbs.map(\.id) == ["room-2"]) #expect(context.viewState.breadcrumbs.map(\.id) == ["room-2"])
@@ -190,7 +190,7 @@ struct SearchScreenViewModelTests {
@Test @Test
func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws { func roomBreadcrumbsAreDisplayedAfterTheRoomListLoads() async throws {
var deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 1 } var deferred = deferFulfillment(context.observe(\.viewState.breadcrumbs)) { $0.count == 1 }
userSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] userSettings.account.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")]
try await deferred.fulfill() try await deferred.fulfill()
#expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second"]) #expect(context.viewState.breadcrumbs.map(\.id) == ["query-Second"])
@@ -458,8 +458,8 @@ final class SecurityAndPrivacyScreenViewModelTests {
joinRule: ElementX.JoinRule, joinRule: ElementX.JoinRule,
forceDisableE2EE: Bool = false) { forceDisableE2EE: Bool = false) {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.knockingEnabled = true userSettings.app.knockingEnabled = true
userSettings.forceDisableE2EE.applyRemoteValue(forceDisableE2EE) userSettings.app.forceDisableE2EE.applyRemoteValue(forceDisableE2EE)
roomProxy = JoinedRoomProxyMock(.init(isEncrypted: false, roomProxy = JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org", canonicalAlias: "#room:matrix.org",
members: .allMembersAsCreator, members: .allMembersAsCreator,
@@ -181,11 +181,11 @@ struct SettingsScreenViewModelTests {
#expect(!context.viewState.showAddAccountButton) #expect(!context.viewState.showAddAccountButton)
var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 } var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 }
userSettings.multiAccountEnabled = true userSettings.app.multiAccountEnabled = true
try await deferred.fulfill() try await deferred.fulfill()
deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 } deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 }
userSettings.multiAccountEnabled = false userSettings.app.multiAccountEnabled = false
try await deferred.fulfill() try await deferred.fulfill()
} }
@@ -900,14 +900,14 @@ final class TimelineViewModelTests {
func disablingTheFlagClearsTheSelection() async throws { func disablingTheFlagClearsTheSelection() async throws {
let items = [TextRoomTimelineItem(eventID: "$1")] let items = [TextRoomTimelineItem(eventID: "$1")]
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.messageMultiSelectEnabled = true userSettings.app.messageMultiSelectEnabled = true
let viewModel = makeViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items)), userSettings: userSettings) let viewModel = makeViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items)), userSettings: userSettings)
startSelection(items[0].id, in: viewModel) startSelection(items[0].id, in: viewModel)
#expect(viewModel.state.messageSelection.isActive) #expect(viewModel.state.messageSelection.isActive)
let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.messageSelection.isEnabled && !$0.messageSelection.isActive } let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.messageSelection.isEnabled && !$0.messageSelection.isActive }
userSettings.messageMultiSelectEnabled = false userSettings.app.messageMultiSelectEnabled = false
try await deferred.fulfill() try await deferred.fulfill()
} }
@@ -945,7 +945,7 @@ final class TimelineViewModelTests {
private func makeSelectionViewModel(timelineController: TimelineControllerProtocol, private func makeSelectionViewModel(timelineController: TimelineControllerProtocol,
userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> TimelineViewModel { userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> TimelineViewModel {
let userSettings = UserSettings.mock() let userSettings = UserSettings.mock()
userSettings.messageMultiSelectEnabled = true userSettings.app.messageMultiSelectEnabled = true
return makeViewModel(timelineController: timelineController, return makeViewModel(timelineController: timelineController,
userIndicatorController: userIndicatorController, userIndicatorController: userIndicatorController,
userSettings: userSettings) userSettings: userSettings)
+31 -31
View File
@@ -24,31 +24,31 @@ struct UserSettingsTests {
@Test @Test
func accountSettingsAreScopedToAccount() { func accountSettingsAreScopedToAccount() {
// Given settings for two different accounts. // Given settings for two different accounts.
#expect(aliceSettings.searchBreadcrumbs.isEmpty) #expect(aliceSettings.account.searchBreadcrumbs.isEmpty)
#expect(bobSettings.searchBreadcrumbs.isEmpty) #expect(bobSettings.account.searchBreadcrumbs.isEmpty)
// When updating an account setting for Alice. // When updating an account setting for Alice.
aliceSettings.searchBreadcrumbs = [.query("Alice")] aliceSettings.account.searchBreadcrumbs = [.query("Alice")]
// Then only Alice should see the change. // Then only Alice should see the change.
#expect(aliceSettings.searchBreadcrumbs == [.query("Alice")]) #expect(aliceSettings.account.searchBreadcrumbs == [.query("Alice")])
#expect(bobSettings.searchBreadcrumbs.isEmpty) #expect(bobSettings.account.searchBreadcrumbs.isEmpty)
} }
@Test @Test
func appSettingsAreShared() { func appSettingsAreShared() {
// Given settings for two different accounts. // Given settings for two different accounts.
#expect(!appSettings.hasSignedInBefore) #expect(!appSettings.hasSignedInBefore)
#expect(!aliceSettings.hasSignedInBefore) #expect(!aliceSettings.app.hasSignedInBefore)
#expect(!bobSettings.hasSignedInBefore) #expect(!bobSettings.app.hasSignedInBefore)
// When updating an app setting through Alice's settings. // When updating an app setting through Alice's settings.
aliceSettings.hasSignedInBefore = true aliceSettings.app.hasSignedInBefore = true
// Then the change should be seen by the app and every account. // Then the change should be seen by the app and every account.
#expect(appSettings.hasSignedInBefore) #expect(appSettings.hasSignedInBefore)
#expect(aliceSettings.hasSignedInBefore) #expect(aliceSettings.app.hasSignedInBefore)
#expect(bobSettings.hasSignedInBefore) #expect(bobSettings.app.hasSignedInBefore)
} }
@Test @Test
@@ -59,18 +59,18 @@ struct UserSettingsTests {
try store.set(JSONEncoder().encode([SearchBreadcrumb.query("Alice")]), forKey: "searchBreadcrumbs") try store.set(JSONEncoder().encode([SearchBreadcrumb.query("Alice")]), forKey: "searchBreadcrumbs")
#expect(store.object(forKey: "hasRunIdentityConfirmationOnboarding") != nil) #expect(store.object(forKey: "hasRunIdentityConfirmationOnboarding") != nil)
#expect(store.object(forKey: "searchBreadcrumbs") != nil) #expect(store.object(forKey: "searchBreadcrumbs") != nil)
#expect(!aliceSettings.hasRunIdentityConfirmationOnboarding) #expect(!aliceSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(aliceSettings.searchBreadcrumbs.isEmpty) #expect(aliceSettings.account.searchBreadcrumbs.isEmpty)
// When migrating those values into Alice's account settings. // When migrating those values into Alice's account settings.
aliceSettings.migrateAppSettingsValueToAccountSettings(\.hasRunIdentityConfirmationOnboardingKey) aliceSettings.account.migrateAppSettingsValue(\.hasRunIdentityConfirmationOnboardingKey)
aliceSettings.migrateAppSettingsValueToAccountSettings(\.searchBreadcrumbsKey) aliceSettings.account.migrateAppSettingsValue(\.searchBreadcrumbsKey)
// Then the values should belong to Alice only. // Then the values should belong to Alice only.
#expect(aliceSettings.hasRunIdentityConfirmationOnboarding) #expect(aliceSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(aliceSettings.searchBreadcrumbs == [.query("Alice")]) #expect(aliceSettings.account.searchBreadcrumbs == [.query("Alice")])
#expect(!bobSettings.hasRunIdentityConfirmationOnboarding) #expect(!bobSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(bobSettings.searchBreadcrumbs.isEmpty) #expect(bobSettings.account.searchBreadcrumbs.isEmpty)
#expect(store.object(forKey: "hasRunIdentityConfirmationOnboarding") == nil) #expect(store.object(forKey: "hasRunIdentityConfirmationOnboarding") == nil)
#expect(store.object(forKey: "searchBreadcrumbs") == nil) #expect(store.object(forKey: "searchBreadcrumbs") == nil)
} }
@@ -79,24 +79,24 @@ struct UserSettingsTests {
func resetSessionSpecificSettings() { func resetSessionSpecificSettings() {
// Given app settings and account settings for two different accounts. // Given app settings and account settings for two different accounts.
appSettings.hasSignedInBefore = true appSettings.hasSignedInBefore = true
aliceSettings.hasRunIdentityConfirmationOnboarding = true aliceSettings.account.hasRunIdentityConfirmationOnboarding = true
aliceSettings.searchBreadcrumbs = [.query("Alice")] aliceSettings.account.searchBreadcrumbs = [.query("Alice")]
bobSettings.hasRunIdentityConfirmationOnboarding = true bobSettings.account.hasRunIdentityConfirmationOnboarding = true
bobSettings.searchBreadcrumbs = [.query("Bob")] bobSettings.account.searchBreadcrumbs = [.query("Bob")]
#expect(appSettings.hasSignedInBefore) #expect(appSettings.hasSignedInBefore)
#expect(aliceSettings.hasRunIdentityConfirmationOnboarding) #expect(aliceSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(aliceSettings.searchBreadcrumbs == [.query("Alice")]) #expect(aliceSettings.account.searchBreadcrumbs == [.query("Alice")])
#expect(bobSettings.hasRunIdentityConfirmationOnboarding) #expect(bobSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(bobSettings.searchBreadcrumbs == [.query("Bob")]) #expect(bobSettings.account.searchBreadcrumbs == [.query("Bob")])
// When resetting Alice's session specific settings. // When resetting Alice's session specific settings.
aliceSettings.resetSessionSpecificSettings() aliceSettings.account.reset()
// Then only Alice's account settings should be reset. // Then only Alice's account settings should be reset.
#expect(!aliceSettings.hasRunIdentityConfirmationOnboarding) #expect(!aliceSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(aliceSettings.searchBreadcrumbs.isEmpty) #expect(aliceSettings.account.searchBreadcrumbs.isEmpty)
#expect(bobSettings.hasRunIdentityConfirmationOnboarding) #expect(bobSettings.account.hasRunIdentityConfirmationOnboarding)
#expect(bobSettings.searchBreadcrumbs == [.query("Bob")]) #expect(bobSettings.account.searchBreadcrumbs == [.query("Bob")])
#expect(appSettings.hasSignedInBefore) #expect(appSettings.hasSignedInBefore)
} }
} }