refactor(healthchat): move the session hooks onto UserHooks

This commit is contained in:
Sedat Qaja
2026-10-19 09:18:07 +02:00
parent 1b34a71857
commit 979d20572d
22 changed files with 725 additions and 409 deletions
File diff suppressed because it is too large Load Diff
+4 -30
View File
@@ -12,12 +12,6 @@ import Synchronization
final nonisolated class AppHooks: AppHooksProtocol { final nonisolated class AppHooks: AppHooksProtocol {
#if IS_MAIN_APP #if IS_MAIN_APP
func configure(with userSession: UserSessionProtocol?) async {
await userSessionHook.configure(with: userSession)
await extendedProfileSectionHook.configure(with: userSession)
await settingsScreenHook.configure(with: userSession)
}
@AppHook(default: DefaultAppSettingsHook()) @AppHook(default: DefaultAppSettingsHook())
var appSettingsHook: AppSettingsHookProtocol var appSettingsHook: AppSettingsHookProtocol
@@ -30,38 +24,15 @@ final nonisolated class AppHooks: AppHooksProtocol {
@AppHook(default: DefaultOAuthPresenterHook()) @AppHook(default: DefaultOAuthPresenterHook())
var oAuthPresenterHook: OAuthPresenterHookProtocol var oAuthPresenterHook: OAuthPresenterHookProtocol
@AppHook(default: DefaultUserSessionHook()) // HealthChat app-level hooks. The session-scoped ones live in `UserHooks`.
var userSessionHook: UserSessionHookProtocol
@AppHook(default: DefaultRoomScreenHook())
var roomScreenHook: RoomScreenHookProtocol
@AppHook(default: DefaultRoomDetailsScreenHook())
var roomDetailsScreenHook: RoomDetailsScreenHookProtocol
@AppHook(default: DefaultRoomMemberDetailsScreenHook())
var roomMemberDetailsScreenHook: RoomMemberDetailsScreenHookProtocol
@AppHook(default: DefaultUserProfileScreenHook())
var userProfileScreenHook: UserProfileScreenHookProtocol
@AppHook(default: DefaultExtendedProfileSectionHook())
var extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
@AppHook(default: DefaultHomeTabsHook()) @AppHook(default: DefaultHomeTabsHook())
var homeTabsHook: HomeTabsHookProtocol var homeTabsHook: HomeTabsHookProtocol
@AppHook(default: DefaultSettingsScreenHook())
var settingsScreenHook: SettingsScreenHookProtocol
@AppHook(default: DefaultUserDetailsEditScreenHook()) @AppHook(default: DefaultUserDetailsEditScreenHook())
var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol var userDetailsEditScreenHook: UserDetailsEditScreenHookProtocol
@AppHook(default: DefaultDeveloperOptionsScreenHook()) @AppHook(default: DefaultDeveloperOptionsScreenHook())
var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol var developerOptionsScreenHook: DeveloperOptionsScreenHookProtocol
@AppHook(default: DefaultRecoveryKeyScreenHook())
var recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol
#endif #endif
@AppHook(default: DefaultTracingHook()) @AppHook(default: DefaultTracingHook())
@@ -72,6 +43,9 @@ final nonisolated class AppHooks: AppHooksProtocol {
@AppHook(default: DefaultRemoteSettingsHook()) @AppHook(default: DefaultRemoteSettingsHook())
var remoteSettingsHook: RemoteSettingsHookProtocol var remoteSettingsHook: RemoteSettingsHookProtocol
@AppHook(default: DefaultUserSessionHook())
var userSessionHook: UserSessionHookProtocol
} }
nonisolated protocol AppHooksProtocol: Sendable { nonisolated protocol AppHooksProtocol: Sendable {
@@ -16,9 +16,8 @@ extension AppHooks {
#if IS_MAIN_APP #if IS_MAIN_APP
registerAppSettingsHook(HealthChatAppSettingsHook()) registerAppSettingsHook(HealthChatAppSettingsHook())
registerCompoundHook(HealthChatCompoundHook()) registerCompoundHook(HealthChatCompoundHook())
registerExtendedProfileSectionHook(HealthChatExtendedProfileSectionHook())
registerHomeTabsHook(HealthChatHomeTabsHook()) registerHomeTabsHook(HealthChatHomeTabsHook())
registerSettingsScreenHook(HealthChatSettingsScreenHook()) registerUserSessionHook(HealthChatUserSessionHook())
registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook()) registerUserDetailsEditScreenHook(HealthChatUserDetailsEditScreenHook())
#endif #endif
} }
@@ -12,20 +12,14 @@ import SwiftUI
// service is main-actor state — the hook's requirements are all @MainActor anyway. // service is main-actor state — the hook's requirements are all @MainActor anyway.
@MainActor @MainActor
final class HealthChatExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol { final class HealthChatExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol {
private var extendedProfileService: ExtendedProfileServiceProtocol? private let extendedProfileService: ExtendedProfileServiceProtocol
// Constructed during hook registration, before the main actor is reachable. init(extendedProfileService: ExtendedProfileServiceProtocol) {
nonisolated init() { } self.extendedProfileService = extendedProfileService
func configure(with userSession: UserSessionProtocol?) async {
extendedProfileService = userSession.map { ExtendedProfileService(clientProxy: $0.clientProxy) }
} }
func extendedProfileSection(userID: String) -> AnyView? { func extendedProfileSection(userID: String) -> AnyView? {
guard let extendedProfileService else { AnyView(HealthChatExtendedProfileSection(userID: userID, service: extendedProfileService))
return nil
}
return AnyView(HealthChatExtendedProfileSection(userID: userID, service: extendedProfileService))
} }
} }
#endif #endif
@@ -14,28 +14,19 @@ import SwiftUI
final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol { final class HealthChatSettingsScreenHook: SettingsScreenHookProtocol {
// Owned here rather than by the section: the settings screen asks for the section on // Owned here rather than by the section: the settings screen asks for the section on
// every body pass. Nothing is fetched until the screen appears and asks for a refresh. // every body pass. Nothing is fetched until the screen appears and asks for a refresh.
private var statusLoader: HealthChatAccountStatusLoader? private let statusLoader: HealthChatAccountStatusLoader
// Constructed during hook registration, before the main actor is reachable. init(statusLoader: HealthChatAccountStatusLoader) {
nonisolated init() { } self.statusLoader = statusLoader
func configure(with userSession: UserSessionProtocol?) async {
statusLoader = userSession.map {
let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: $0.clientProxy))
return HealthChatAccountStatusLoader(apiService: apiService)
}
} }
func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? {
guard let statusLoader else { AnyView(HealthChatAccountStatusSection(loader: statusLoader,
return nil completeProfileAction: completeProfileAction))
}
return AnyView(HealthChatAccountStatusSection(loader: statusLoader,
completeProfileAction: completeProfileAction))
} }
func refreshAccountStatus() { func refreshAccountStatus() {
statusLoader?.load() statusLoader.load()
} }
} }
#endif #endif
@@ -0,0 +1,23 @@
//
// Copyright 2026 Novaloop AG
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
#if IS_MAIN_APP
/// Builds the HealthChat session-scoped hooks when a user session is created.
nonisolated struct HealthChatUserSessionHook: UserSessionHookProtocol {
func makeUserHooks(for clientProxy: ClientProxyProtocol) -> UserHooks {
let apiService = HealthChatAPIService(tokenProvider: HealthChatOpenIDTokenProvider(clientProxy: clientProxy))
return UserHooks(extendedProfileSectionHook: HealthChatExtendedProfileSectionHook(extendedProfileService: ExtendedProfileService(clientProxy: clientProxy)),
settingsScreenHook: HealthChatSettingsScreenHook(statusLoader: HealthChatAccountStatusLoader(apiService: apiService)))
}
func configure(_ userSettings: UserSettings, forHomeserver homeserver: String) -> UserSettings {
userSettings
}
}
#endif
@@ -8,16 +8,12 @@
import SwiftUI import SwiftUI
nonisolated protocol ExtendedProfileSectionHookProtocol: Sendable { nonisolated protocol ExtendedProfileSectionHookProtocol: Sendable {
@MainActor func configure(with userSession: UserSessionProtocol?) async
/// An extra Form section rendered below the header on the user profile /// An extra Form section rendered below the header on the user profile
/// and room member details screens. /// and room member details screens.
@MainActor func extendedProfileSection(userID: String) -> AnyView? @MainActor func extendedProfileSection(userID: String) -> AnyView?
} }
struct DefaultExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol { struct DefaultExtendedProfileSectionHook: ExtendedProfileSectionHookProtocol {
func configure(with userSession: UserSessionProtocol?) async { }
func extendedProfileSection(userID: String) -> AnyView? { func extendedProfileSection(userID: String) -> AnyView? {
nil nil
} }
@@ -8,8 +8,6 @@
import SwiftUI import SwiftUI
nonisolated protocol SettingsScreenHookProtocol: Sendable { nonisolated protocol SettingsScreenHookProtocol: Sendable {
@MainActor func configure(with userSession: UserSessionProtocol?) async
/// An extra Form section rendered between the user and the account sections, /// An extra Form section rendered between the user and the account sections,
/// advertising whatever is holding the account back. /// advertising whatever is holding the account back.
/// - Parameter completeProfileAction: Opens the own profile editor. /// - Parameter completeProfileAction: Opens the own profile editor.
@@ -20,8 +18,6 @@ nonisolated protocol SettingsScreenHookProtocol: Sendable {
} }
struct DefaultSettingsScreenHook: SettingsScreenHookProtocol { struct DefaultSettingsScreenHook: SettingsScreenHookProtocol {
func configure(with userSession: UserSessionProtocol?) async { }
func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? { func accountStatusSection(completeProfileAction: @escaping () -> Void) -> AnyView? {
nil nil
} }
+8 -1
View File
@@ -15,6 +15,9 @@ nonisolated struct UserHooks: Sendable {
let recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol let recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol
let createRoomScreenHook: CreateRoomScreenHookProtocol let createRoomScreenHook: CreateRoomScreenHookProtocol
let securityAndPrivacyScreenHook: SecurityAndPrivacyScreenHookProtocol let securityAndPrivacyScreenHook: SecurityAndPrivacyScreenHookProtocol
// HealthChat: session-scoped hooks of the fork. Kept here, next to upstream's, so screens reach them the same way.
let extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
let settingsScreenHook: SettingsScreenHookProtocol
init(roomScreenHook: RoomScreenHookProtocol = DefaultRoomScreenHook(), init(roomScreenHook: RoomScreenHookProtocol = DefaultRoomScreenHook(),
roomDetailsScreenHook: RoomDetailsScreenHookProtocol = DefaultRoomDetailsScreenHook(), roomDetailsScreenHook: RoomDetailsScreenHookProtocol = DefaultRoomDetailsScreenHook(),
@@ -22,7 +25,9 @@ nonisolated struct UserHooks: Sendable {
userProfileScreenHook: UserProfileScreenHookProtocol = DefaultUserProfileScreenHook(), userProfileScreenHook: UserProfileScreenHookProtocol = DefaultUserProfileScreenHook(),
recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol = DefaultRecoveryKeyScreenHook(), recoveryKeyScreenHook: RecoveryKeyScreenHookProtocol = DefaultRecoveryKeyScreenHook(),
createRoomScreenHook: CreateRoomScreenHookProtocol = DefaultCreateRoomScreenHook(), createRoomScreenHook: CreateRoomScreenHookProtocol = DefaultCreateRoomScreenHook(),
securityAndPrivacyScreenHook: SecurityAndPrivacyScreenHookProtocol = DefaultSecurityAndPrivacyScreenHook()) { securityAndPrivacyScreenHook: SecurityAndPrivacyScreenHookProtocol = DefaultSecurityAndPrivacyScreenHook(),
extendedProfileSectionHook: ExtendedProfileSectionHookProtocol = DefaultExtendedProfileSectionHook(),
settingsScreenHook: SettingsScreenHookProtocol = DefaultSettingsScreenHook()) {
self.roomScreenHook = roomScreenHook self.roomScreenHook = roomScreenHook
self.roomDetailsScreenHook = roomDetailsScreenHook self.roomDetailsScreenHook = roomDetailsScreenHook
self.roomMemberDetailsScreenHook = roomMemberDetailsScreenHook self.roomMemberDetailsScreenHook = roomMemberDetailsScreenHook
@@ -30,5 +35,7 @@ nonisolated struct UserHooks: Sendable {
self.recoveryKeyScreenHook = recoveryKeyScreenHook self.recoveryKeyScreenHook = recoveryKeyScreenHook
self.createRoomScreenHook = createRoomScreenHook self.createRoomScreenHook = createRoomScreenHook
self.securityAndPrivacyScreenHook = securityAndPrivacyScreenHook self.securityAndPrivacyScreenHook = securityAndPrivacyScreenHook
self.extendedProfileSectionHook = extendedProfileSectionHook
self.settingsScreenHook = settingsScreenHook
} }
} }
@@ -121,7 +121,6 @@ final class HealthChatMembersFlowCoordinator {
let parameters = UserProfileScreenCoordinatorParameters(userID: userID, let parameters = UserProfileScreenCoordinatorParameters(userID: userID,
isPresentedModally: false, isPresentedModally: false,
userSession: flowParameters.userSession, userSession: flowParameters.userSession,
appHooks: flowParameters.appHooks,
analytics: flowParameters.analytics, analytics: flowParameters.analytics,
userIndicatorController: flowParameters.userIndicatorController) userIndicatorController: flowParameters.userIndicatorController)
let coordinator = UserProfileScreenCoordinator(parameters: parameters) let coordinator = UserProfileScreenCoordinator(parameters: parameters)
@@ -148,7 +147,7 @@ final class HealthChatMembersFlowCoordinator {
private func presentProfileEditor() { private func presentProfileEditor() {
let parameters = UserDetailsEditScreenCoordinatorParameters(orientationManager: flowParameters.windowManager, let parameters = UserDetailsEditScreenCoordinatorParameters(orientationManager: flowParameters.windowManager,
userSession: flowParameters.userSession, userSession: flowParameters.userSession,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: flowParameters.userSession.userSettings),
navigationStackCoordinator: navigationStackCoordinator, navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: flowParameters.userIndicatorController) userIndicatorController: flowParameters.userIndicatorController)
let coordinator = flowParameters.appHooks.userDetailsEditScreenHook.makeCoordinator(parameters: parameters) let coordinator = flowParameters.appHooks.userDetailsEditScreenHook.makeCoordinator(parameters: parameters)
@@ -237,16 +237,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} }
private func setupObservers() { private func setupObservers() {
extraTabActions? setupExtraTabObserver()
.sink { [weak self] action in
switch action {
case .presentRoom(let roomID):
self?.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
case .presentCall(let roomID, let isVoiceCall):
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), animated: true)
}
}
.store(in: &cancellables)
chatsTabFlowCoordinator.actionsPublisher chatsTabFlowCoordinator.actionsPublisher
.sink { [weak self] action in .sink { [weak self] action in
@@ -358,6 +349,20 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.store(in: &cancellables) .store(in: &cancellables)
} }
/// HealthChat: routes the Members tab's actions through the normal app-route handling.
private func setupExtraTabObserver() {
extraTabActions?
.sink { [weak self] action in
switch action {
case .presentRoom(let roomID):
self?.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
case .presentCall(let roomID, let isVoiceCall):
self?.handleAppRoute(.call(roomID: roomID, isVoiceCall: isVoiceCall), animated: true)
}
}
.store(in: &cancellables)
}
// MARK: - Onboarding // MARK: - Onboarding
private func attemptStartingOnboarding() { private func attemptStartingOnboarding() {
@@ -214,7 +214,7 @@ struct HealthChatProfileEditScreen_Previews: PreviewProvider, TestablePreview {
return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))), return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
apiService: apiService, apiService: apiService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: .mock()),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }
} }
@@ -17,7 +17,7 @@ enum RoomMemberDetailsScreenViewModelAction {
struct RoomMemberDetailsScreenViewState: BindableState { struct RoomMemberDetailsScreenViewState: BindableState {
let userID: String let userID: String
let appHooks: AppHooks let extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
var memberDetails: RoomMemberDetails? var memberDetails: RoomMemberDetails?
var verificationState: UserIdentityVerificationState? var verificationState: UserIdentityVerificationState?
var isOwnMemberDetails = false var isOwnMemberDetails = false
@@ -35,7 +35,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
self.analytics = analytics self.analytics = analytics
let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, appHooks: appHooks, bindings: .init()) let initialViewState = RoomMemberDetailsScreenViewState(userID: userID, extendedProfileSectionHook: userSession.userHooks.extendedProfileSectionHook, bindings: .init())
super.init(initialViewState: userSession.userHooks.roomMemberDetailsScreenHook.update(initialViewState), super.init(initialViewState: userSession.userHooks.roomMemberDetailsScreenHook.update(initialViewState),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
@@ -16,7 +16,7 @@ struct RoomMemberDetailsScreen: View {
Form { Form {
headerSection headerSection
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID) context.viewState.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
if context.viewState.showVerifyIdentitySection { if context.viewState.showVerifyIdentitySection {
verificationSection verificationSection
@@ -10,22 +10,30 @@ import SwiftUI
enum SettingsScreenViewModelAction { enum SettingsScreenViewModelAction {
case close case close
case userDetails case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation) case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case linkNewDevice
case addAccount
case manageAccount(url: URL) case manageAccount(url: URL)
case analytics case linkNewDevice
case appLock
case reportBug
case about
case blockedUsers
case secureBackup
case notifications case notifications
case advancedSettings case secureBackup
case labs case moderationAndSafety
case developerOptions case reportBug
case logout case logout
case deactivateAccount case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case developerOptions
} }
enum SettingsScreenSecuritySectionMode { enum SettingsScreenSecuritySectionMode {
@@ -38,6 +46,7 @@ struct SettingsScreenViewState: BindableState {
var userProfile: UserProfile var userProfile: UserProfile
var showUserStatusInput = false var showUserStatusInput = false
var showLinkNewDeviceButton: Bool var showLinkNewDeviceButton: Bool
var showAddAccountButton: Bool
var accountProfileURL: URL? var accountProfileURL: URL?
var showAccountDeactivation: Bool var showAccountDeactivation: Bool
var showDeveloperOptions: Bool var showDeveloperOptions: Bool
@@ -45,16 +54,14 @@ struct SettingsScreenViewState: BindableState {
var securitySectionMode = SettingsScreenSecuritySectionMode.none var securitySectionMode = SettingsScreenSecuritySectionMode.none
var showSecuritySectionBadge = false var showSecuritySectionBadge = false
var showBlockedUsers = false
let showAnalyticsSettings: Bool let showAnalyticsSettings: Bool
let isBugReportServiceEnabled: Bool let isBugReportServiceEnabled: Bool
let navigationBarVisibility: Visibility let navigationBarVisibility: Visibility
let settingsScreenHook: SettingsScreenHookProtocol
let appHooks: AppHooks var bindings: SettingsScreenViewStateBindings
var bindings = SettingsScreenViewStateBindings()
var userStatusRowMode: SettingsScreenUserStatusRow.Mode { var userStatusRowMode: SettingsScreenUserStatusRow.Mode {
if bindings.isShowingCustomStatusField { if bindings.isShowingCustomStatusField {
@@ -68,6 +75,8 @@ struct SettingsScreenViewState: BindableState {
} }
struct SettingsScreenViewStateBindings { struct SettingsScreenViewStateBindings {
private let userSettings: UserSettings
var isPresentingStatusPicker = false var isPresentingStatusPicker = false
var customStatusEmoji: Character = "😄" var customStatusEmoji: Character = "😄"
var isShowingCustomStatusField = false { var isShowingCustomStatusField = false {
@@ -79,28 +88,45 @@ struct SettingsScreenViewStateBindings {
} }
var isPresentingAccountDeactivationConfirmation = false var isPresentingAccountDeactivationConfirmation = false
var appAppearance: AppAppearance {
get { userSettings.app.appAppearance }
set { userSettings.app.appAppearance = newValue }
}
init(userSettings: UserSettings) {
self.userSettings = userSettings
}
} }
enum SettingsScreenViewAction { enum SettingsScreenViewAction {
case close case close
case userDetails case userDetails
case userStatus(UserStatusAction) case userStatus(UserStatusAction)
case analytics
case appLock case addAccount
case reportBug
case about
case blockedUsers
case secureBackup
case linkNewDevice
case manageAccount(url: URL) case manageAccount(url: URL)
case linkNewDevice
case notifications case notifications
case enableDeveloperOptions case secureBackup
case developerOptions case moderationAndSafety
case advancedSettings case reportBug
case labs
case logout case logout
case deactivateAccount case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case enableDeveloperOptions
case developerOptions
enum UserStatusAction { enum UserStatusAction {
/// Show status picker sheet to select a preset status. /// Show status picker sheet to select a preset status.
case pickStatus case pickStatus
@@ -12,7 +12,7 @@ import SwiftUI
typealias SettingsScreenViewModelType = StateStoreViewModelV2<SettingsScreenViewState, SettingsScreenViewAction> typealias SettingsScreenViewModelType = StateStoreViewModelV2<SettingsScreenViewState, SettingsScreenViewAction>
class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol { class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol {
private let appSettings: AppSettings private let userSettings: UserSettings
private let clientProxy: ClientProxyProtocol private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol private let userIndicatorController: UserIndicatorControllerProtocol
@@ -22,30 +22,36 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
init(userSession: UserSessionProtocol, appSettings: AppSettings, appHooks: AppHooks, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) { init(userSession: UserSessionProtocol, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) {
self.appSettings = appSettings userSettings = userSession.userSettings
clientProxy = userSession.clientProxy clientProxy = userSession.clientProxy
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
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: appSettings.linkNewDeviceEnabled, showLinkNewDeviceButton: userSettings.app.linkNewDeviceEnabled,
showAddAccountButton: userSettings.app.multiAccountEnabled,
showAccountDeactivation: userSession.clientProxy.canDeactivateAccount, showAccountDeactivation: userSession.clientProxy.canDeactivateAccount,
showDeveloperOptions: appSettings.developerOptionsEnabled, showDeveloperOptions: userSettings.app.developerOptionsEnabled,
showAnalyticsSettings: appSettings.canPromptForAnalytics, showAnalyticsSettings: userSettings.app.canPromptForAnalytics,
isBugReportServiceEnabled: isBugReportServiceEnabled, isBugReportServiceEnabled: isBugReportServiceEnabled,
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic, navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic,
appHooks: appHooks), settingsScreenHook: userSession.userHooks.settingsScreenHook,
bindings: .init(userSettings: userSettings)),
mediaProvider: userSession.mediaProvider) mediaProvider: userSession.mediaProvider)
appSettings.developerOptionsEnabledPublisher userSettings.app.developerOptionsEnabledPublisher
.weakAssign(to: \.state.showDeveloperOptions, on: self) .weakAssign(to: \.state.showDeveloperOptions, on: self)
.store(in: &cancellables) .store(in: &cancellables)
appSettings.linkNewDeviceEnabledPublisher userSettings.app.linkNewDeviceEnabledPublisher
.weakAssign(to: \.state.showLinkNewDeviceButton, on: self) .weakAssign(to: \.state.showLinkNewDeviceButton, on: self)
.store(in: &cancellables) .store(in: &cancellables)
userSettings.app.multiAccountEnabledPublisher
.weakAssign(to: \.state.showAddAccountButton, on: self)
.store(in: &cancellables)
userSession.clientProxy.userProfilePublisher userSession.clientProxy.userProfilePublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userProfile, on: self) .weakAssign(to: \.state.userProfile, on: self)
@@ -73,18 +79,6 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
} }
.store(in: &cancellables) .store(in: &cancellables)
userSession.clientProxy.ignoredUsersPublisher
.receive(on: DispatchQueue.main)
.map {
guard let blockedUsers = $0 else {
return false
}
return !blockedUsers.isEmpty
}
.weakAssign(to: \.state.showBlockedUsers, on: self)
.store(in: &cancellables)
Task { Task {
if case .success(true) = await userSession.clientProxy.isUserStatusSupported() { if case .success(true) = await userSession.clientProxy.isUserStatusSupported() {
state.showUserStatusInput = true state.showUserStatusInput = true
@@ -98,6 +92,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
switch viewAction { switch viewAction {
case .close: case .close:
actionsSubject.send(.close) actionsSubject.send(.close)
case .userDetails: case .userDetails:
actionsSubject.send(.userDetails) actionsSubject.send(.userDetails)
case .userStatus(.pickStatus): case .userStatus(.pickStatus):
@@ -114,36 +109,45 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
case .userStatus(.cancel): case .userStatus(.cancel):
state.bindings.isPresentingStatusPicker = false state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false state.bindings.isShowingCustomStatusField = false
case .linkNewDevice:
actionsSubject.send(.linkNewDevice) case .addAccount:
actionsSubject.send(.addAccount)
case let .manageAccount(url): case let .manageAccount(url):
actionsSubject.send(.manageAccount(url: url)) actionsSubject.send(.manageAccount(url: url))
case .analytics: case .linkNewDevice:
actionsSubject.send(.analytics) actionsSubject.send(.linkNewDevice)
case .appLock:
actionsSubject.send(.appLock)
case .reportBug:
actionsSubject.send(.reportBug)
case .about:
actionsSubject.send(.about)
case .blockedUsers:
actionsSubject.send(.blockedUsers)
case .logout:
actionsSubject.send(.logout)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .notifications: case .notifications:
actionsSubject.send(.notifications) actionsSubject.send(.notifications)
case .advancedSettings: case .secureBackup:
actionsSubject.send(.advancedSettings) actionsSubject.send(.secureBackup)
case .labs: case .moderationAndSafety:
actionsSubject.send(.labs) actionsSubject.send(.moderationAndSafety)
case .enableDeveloperOptions: case .reportBug:
appSettings.developerOptionsEnabled.toggle() actionsSubject.send(.reportBug)
case .developerOptions:
actionsSubject.send(.developerOptions) case .logout:
actionsSubject.send(.logout)
case .deactivateAccount: case .deactivateAccount:
actionsSubject.send(.deactivateAccount) actionsSubject.send(.deactivateAccount)
case .mediaUploadQuality:
actionsSubject.send(.mediaUploadQuality)
case .appLock:
actionsSubject.send(.appLock)
case .locationSharing:
actionsSubject.send(.locationSharing)
case .analytics:
actionsSubject.send(.analytics)
case .labs:
actionsSubject.send(.labs)
case .about:
actionsSubject.send(.about)
case .enableDeveloperOptions:
userSettings.app.developerOptionsEnabled.toggle()
case .developerOptions:
actionsSubject.send(.developerOptions)
} }
} }
@@ -13,44 +13,30 @@ import SwiftUI
struct SettingsScreen: View { struct SettingsScreen: View {
@Bindable var context: SettingsScreenViewModel.Context @Bindable var context: SettingsScreenViewModel.Context
private var shouldHideManageAccountSection: Bool {
context.viewState.accountProfileURL == nil &&
!context.viewState.showBlockedUsers &&
!context.viewState.showLinkNewDeviceButton
}
var body: some View { var body: some View {
Form { Form {
userSection userSection
if context.viewState.showUserStatusInput { context.viewState.settingsScreenHook.accountStatusSection {
userStatusSection
}
context.viewState.appHooks.settingsScreenHook.accountStatusSection {
context.send(viewAction: .userDetails) context.send(viewAction: .userDetails)
} }
if !shouldHideManageAccountSection { if context.viewState.showAddAccountButton {
manageAccountSection addAccountSection
} }
manageMyAppSection accountSettingsSection
generalSection
signOutSection signOutSection
if context.viewState.showDeveloperOptions { appSettingsSection
developerOptionsSection
}
} }
.compoundList() .compoundList()
.navigationTitle(L10n.commonSettings) .navigationTitle(L10n.commonSettings)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar) .toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar)
.toolbar { toolbar } .toolbar { toolbar }
.onAppear { context.viewState.appHooks.settingsScreenHook.refreshAccountStatus() } .onAppear { context.viewState.settingsScreenHook.refreshAccountStatus() }
.sheet(isPresented: $context.isPresentingStatusPicker) { .sheet(isPresented: $context.isPresentingStatusPicker) {
SettingsScreenUserStatusPickerView { action in SettingsScreenUserStatusPickerView { action in
context.send(viewAction: .userStatus(action)) context.send(viewAction: .userStatus(action))
@@ -75,15 +61,9 @@ struct SettingsScreen: View {
.accessibilityHidden(true) .accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) { Text(context.viewState.userProfile.displayName ?? "")
Text(context.viewState.userProfile.displayName ?? "") .font(.compound.headingMD)
.foregroundColor(.compound.textPrimary)
if let statusEmoji = context.viewState.userProfile.status.displayed?.emoji {
Text(String(statusEmoji))
}
}
.font(.compound.headingMD)
.foregroundColor(.compound.textPrimary)
Text(context.viewState.userProfile.id) Text(context.viewState.userProfile.id)
.font(.compound.bodySM) .font(.compound.bodySM)
@@ -98,47 +78,27 @@ struct SettingsScreen: View {
.padding(.vertical, 8) .padding(.vertical, 8)
} }
}) })
}
} if context.viewState.showUserStatusInput {
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
private var userStatusSection: some View { context.send(viewAction: .userStatus(action))
Section { }
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
context.send(viewAction: .userStatus(action))
} }
} }
} }
private var manageMyAppSection: some View { private var addAccountSection: some View {
Section { Section {
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle, ListRow(label: .default(title: L10n.screenSettingsAddAccount,
icon: \.notifications), icon: \.plus),
kind: .navigationLink { kind: .navigationLink {
context.send(viewAction: .notifications) context.send(viewAction: .addAccount)
}) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications) .accessibilityIdentifier(A11yIdentifiers.settingsScreen.addAccount)
ListRow(label: .default(title: L10n.commonScreenLock,
icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .appLock)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock)
switch context.viewState.securitySectionMode {
case .secureBackup:
ListRow(label: .default(title: L10n.commonEncryption,
icon: \.key),
details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil,
kind: .navigationLink { context.send(viewAction: .secureBackup) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup)
default:
EmptyView()
}
} }
} }
private var manageAccountSection: some View { private var accountSettingsSection: some View {
Section { Section {
if let url = context.viewState.accountProfileURL { if let url = context.viewState.accountProfileURL {
ListRow(label: .default(title: L10n.actionManageAccountAndDevices, ListRow(label: .default(title: L10n.actionManageAccountAndDevices,
@@ -157,39 +117,30 @@ struct SettingsScreen: View {
}) })
} }
if context.viewState.showBlockedUsers { ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
ListRow(label: .default(title: L10n.commonBlockedUsers, icon: \.notifications),
icon: \.block), kind: .navigationLink {
kind: .navigationLink { context.send(viewAction: .notifications)
context.send(viewAction: .blockedUsers) })
}) .accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications)
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.blockedUsers)
switch context.viewState.securitySectionMode {
case .secureBackup:
ListRow(label: .default(title: L10n.commonEncryption,
icon: \.key),
details: context.viewState.showSecuritySectionBadge ? .icon(securitySectionBadge) : nil,
kind: .navigationLink { context.send(viewAction: .secureBackup) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.secureBackup)
default:
EmptyView()
} }
}
}
private var generalSection: some View {
Section {
ListRow(label: .default(title: L10n.commonAdvancedSettings,
icon: \.settings),
kind: .navigationLink {
context.send(viewAction: .advancedSettings)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.advancedSettings)
ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs, ListRow(label: .default(title: L10n.commonModerationAndSafety,
icon: \.labs), icon: \.admin),
kind: .navigationLink { kind: .navigationLink {
context.send(viewAction: .labs) context.send(viewAction: .moderationAndSafety)
}) })
ListRow(label: .default(title: L10n.commonAbout,
icon: \.info),
kind: .navigationLink {
context.send(viewAction: .about)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.about)
if context.viewState.isBugReportServiceEnabled { if context.viewState.isBugReportServiceEnabled {
ListRow(label: .default(title: L10n.commonReportAProblem, ListRow(label: .default(title: L10n.commonReportAProblem,
icon: \.chatProblem), icon: \.chatProblem),
@@ -198,15 +149,9 @@ struct SettingsScreen: View {
}) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.reportBug) .accessibilityIdentifier(A11yIdentifiers.settingsScreen.reportBug)
} }
} header: {
if context.viewState.showAnalyticsSettings { Text(L10n.commonAccountSettings)
ListRow(label: .default(title: L10n.commonAnalytics, .compoundListSectionHeader()
icon: \.chart),
kind: .navigationLink {
context.send(viewAction: .analytics)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics)
}
} }
} }
@@ -228,21 +173,68 @@ struct SettingsScreen: View {
context.send(viewAction: .deactivateAccount) context.send(viewAction: .deactivateAccount)
}) })
} }
} footer: {
if !context.viewState.showDeveloperOptions {
versionSection
}
} }
} }
private var developerOptionsSection: some View { private var appSettingsSection: some View {
Section { Section {
ListRow(label: .default(title: L10n.commonDeveloperOptions, ListRow(label: .default(title: L10n.commonAppearance,
icon: \.code), icon: \.darkMode),
kind: .picker(selection: $context.appAppearance,
items: AppAppearance.allCases.map { (title: $0.name, tag: $0) }))
ListRow(label: .default(title: L10n.commonMediaUploadQuality,
icon: \.image),
kind: .navigationLink { kind: .navigationLink {
context.send(viewAction: .developerOptions) context.send(viewAction: .mediaUploadQuality)
}) })
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions)
ListRow(label: .default(title: L10n.commonScreenLock,
icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .appLock)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.screenLock)
ListRow(label: .default(title: L10n.commonLocationSharing,
icon: \.locationPin),
kind: .navigationLink {
context.send(viewAction: .locationSharing)
})
if context.viewState.showAnalyticsSettings {
ListRow(label: .default(title: L10n.commonAnalytics,
icon: \.chart),
kind: .navigationLink {
context.send(viewAction: .analytics)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics)
}
ListRow(label: .default(title: L10n.screenAdvancedSettingsLabs,
icon: \.labs),
kind: .navigationLink {
context.send(viewAction: .labs)
})
ListRow(label: .default(title: L10n.commonAbout,
icon: \.info),
kind: .navigationLink {
context.send(viewAction: .about)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.about)
if context.viewState.showDeveloperOptions {
ListRow(label: .default(title: L10n.commonDeveloperOptions,
icon: \.code),
kind: .navigationLink {
context.send(viewAction: .developerOptions)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.developerOptions)
}
} header: {
Text(L10n.commonAppSettings)
.compoundListSectionHeader()
} footer: { } footer: {
versionSection versionSection
} }
@@ -287,6 +279,19 @@ struct SettingsScreen: View {
} }
} }
private extension AppAppearance {
var name: String {
switch self {
case .system:
L10n.themeSystem
case .light:
L10n.themeLight
case .dark:
L10n.themeDark
}
}
}
// MARK: - Previews // MARK: - Previews
@available(iOS 26.0, *) @available(iOS 26.0, *)
@@ -299,7 +304,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: viewModel.context) SettingsScreen(context: viewModel.context)
} }
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) .snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1100) .frame(height: 1300)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.previewDisplayName("Default") .previewDisplayName("Default")
@@ -307,19 +312,21 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: bugReportDisabledViewModel.context) SettingsScreen(context: bugReportDisabledViewModel.context)
} }
.snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil }) .snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1050) .frame(height: 1250)
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.previewDisplayName("Bug report disabled") .previewDisplayName("Bug report disabled")
} }
static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> SettingsScreenViewModel { static func makeViewModel(isBugReportServiceEnabled: Bool = true) -> SettingsScreenViewModel {
let userSettings = UserSettings.mock()
userSettings.app.multiAccountEnabled = true
userSettings.app.linkNewDeviceEnabled = true
let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@alice:example.com", let userSession = UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: "@alice:example.com",
deviceID: "AAAAAAAAAAA", deviceID: "AAAAAAAAAAA",
displayName: "Alice Liddell", displayName: "Alice Liddell",
status: .mockFocussing)))) status: .mockFocussing)),
userSettings: userSettings))
return SettingsScreenViewModel(userSession: userSession, return SettingsScreenViewModel(userSession: userSession,
appSettings: .volatile(),
appHooks: AppHooks(),
isBugReportServiceEnabled: isBugReportServiceEnabled, isBugReportServiceEnabled: isBugReportServiceEnabled,
isInSecondaryWindow: false, isInSecondaryWindow: false,
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
@@ -18,7 +18,7 @@ struct UserProfileScreenViewState: BindableState {
let userID: String let userID: String
let isOwnUser: Bool let isOwnUser: Bool
let isPresentedModally: Bool let isPresentedModally: Bool
let appHooks: AppHooks let extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
var userProfile: UserProfile? var userProfile: UserProfile?
var isIdentityKnown = false var isIdentityKnown = false
@@ -34,7 +34,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
let initialViewState = UserProfileScreenViewState(userID: userID, let initialViewState = UserProfileScreenViewState(userID: userID,
isOwnUser: userID == userSession.clientProxy.userID, isOwnUser: userID == userSession.clientProxy.userID,
isPresentedModally: isPresentedModally, isPresentedModally: isPresentedModally,
appHooks: appHooks, extendedProfileSectionHook: userSession.userHooks.extendedProfileSectionHook,
bindings: .init()) bindings: .init())
super.init(initialViewState: userSession.userHooks.userProfileScreenHook.update(initialViewState), super.init(initialViewState: userSession.userHooks.userProfileScreenHook.update(initialViewState),
@@ -16,7 +16,7 @@ struct UserProfileScreen: View {
Form { Form {
headerSection headerSection
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID) context.viewState.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
} }
.compoundList() .compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle) .navigationTitle(L10n.screenRoomMemberDetailsTitle)
@@ -113,7 +113,7 @@ final class HealthChatProfileEditScreenViewModelTests {
private func makeViewModel() -> HealthChatProfileEditScreenViewModel { private func makeViewModel() -> HealthChatProfileEditScreenViewModel {
HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
apiService: apiService, apiService: apiService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()), mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: .mock()),
userIndicatorController: UserIndicatorControllerMock()) userIndicatorController: UserIndicatorControllerMock())
} }