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
@@ -214,7 +214,7 @@ struct HealthChatProfileEditScreen_Previews: PreviewProvider, TestablePreview {
return HealthChatProfileEditScreenViewModel(userSession: UserSessionMock(.init(clientProxy: ClientProxyMock(.init()))),
apiService: apiService,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: .volatile()),
mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: .mock()),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -17,7 +17,7 @@ enum RoomMemberDetailsScreenViewModelAction {
struct RoomMemberDetailsScreenViewState: BindableState {
let userID: String
let appHooks: AppHooks
let extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
var memberDetails: RoomMemberDetails?
var verificationState: UserIdentityVerificationState?
var isOwnMemberDetails = false
@@ -35,7 +35,7 @@ class RoomMemberDetailsScreenViewModel: RoomMemberDetailsScreenViewModelType, Ro
self.userIndicatorController = userIndicatorController
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),
mediaProvider: userSession.mediaProvider)
@@ -16,7 +16,7 @@ struct RoomMemberDetailsScreen: View {
Form {
headerSection
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
context.viewState.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
if context.viewState.showVerifyIdentitySection {
verificationSection
@@ -10,22 +10,30 @@ import SwiftUI
enum SettingsScreenViewModelAction {
case close
case userDetails
case userStatusEmojiPicker(EmojiPickerScreenContinuation)
case linkNewDevice
case addAccount
case manageAccount(url: URL)
case analytics
case appLock
case reportBug
case about
case blockedUsers
case secureBackup
case linkNewDevice
case notifications
case advancedSettings
case labs
case developerOptions
case secureBackup
case moderationAndSafety
case reportBug
case logout
case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case developerOptions
}
enum SettingsScreenSecuritySectionMode {
@@ -38,6 +46,7 @@ struct SettingsScreenViewState: BindableState {
var userProfile: UserProfile
var showUserStatusInput = false
var showLinkNewDeviceButton: Bool
var showAddAccountButton: Bool
var accountProfileURL: URL?
var showAccountDeactivation: Bool
var showDeveloperOptions: Bool
@@ -45,16 +54,14 @@ struct SettingsScreenViewState: BindableState {
var securitySectionMode = SettingsScreenSecuritySectionMode.none
var showSecuritySectionBadge = false
var showBlockedUsers = false
let showAnalyticsSettings: Bool
let isBugReportServiceEnabled: Bool
let navigationBarVisibility: Visibility
let settingsScreenHook: SettingsScreenHookProtocol
let appHooks: AppHooks
var bindings = SettingsScreenViewStateBindings()
var bindings: SettingsScreenViewStateBindings
var userStatusRowMode: SettingsScreenUserStatusRow.Mode {
if bindings.isShowingCustomStatusField {
@@ -68,6 +75,8 @@ struct SettingsScreenViewState: BindableState {
}
struct SettingsScreenViewStateBindings {
private let userSettings: UserSettings
var isPresentingStatusPicker = false
var customStatusEmoji: Character = "😄"
var isShowingCustomStatusField = false {
@@ -79,28 +88,45 @@ struct SettingsScreenViewStateBindings {
}
var isPresentingAccountDeactivationConfirmation = false
var appAppearance: AppAppearance {
get { userSettings.app.appAppearance }
set { userSettings.app.appAppearance = newValue }
}
init(userSettings: UserSettings) {
self.userSettings = userSettings
}
}
enum SettingsScreenViewAction {
case close
case userDetails
case userStatus(UserStatusAction)
case analytics
case appLock
case reportBug
case about
case blockedUsers
case secureBackup
case linkNewDevice
case addAccount
case manageAccount(url: URL)
case linkNewDevice
case notifications
case enableDeveloperOptions
case developerOptions
case advancedSettings
case labs
case secureBackup
case moderationAndSafety
case reportBug
case logout
case deactivateAccount
case mediaUploadQuality
case appLock
case locationSharing
case analytics
case labs
case about
case enableDeveloperOptions
case developerOptions
enum UserStatusAction {
/// Show status picker sheet to select a preset status.
case pickStatus
@@ -12,7 +12,7 @@ import SwiftUI
typealias SettingsScreenViewModelType = StateStoreViewModelV2<SettingsScreenViewState, SettingsScreenViewAction>
class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol {
private let appSettings: AppSettings
private let userSettings: UserSettings
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
@@ -22,30 +22,36 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
actionsSubject.eraseToAnyPublisher()
}
init(userSession: UserSessionProtocol, appSettings: AppSettings, appHooks: AppHooks, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) {
self.appSettings = appSettings
init(userSession: UserSessionProtocol, isBugReportServiceEnabled: Bool, isInSecondaryWindow: Bool, userIndicatorController: UserIndicatorControllerProtocol) {
userSettings = userSession.userSettings
clientProxy = userSession.clientProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: .init(deviceID: userSession.clientProxy.deviceID,
userProfile: userSession.clientProxy.userProfilePublisher.value,
showLinkNewDeviceButton: appSettings.linkNewDeviceEnabled,
showLinkNewDeviceButton: userSettings.app.linkNewDeviceEnabled,
showAddAccountButton: userSettings.app.multiAccountEnabled,
showAccountDeactivation: userSession.clientProxy.canDeactivateAccount,
showDeveloperOptions: appSettings.developerOptionsEnabled,
showAnalyticsSettings: appSettings.canPromptForAnalytics,
showDeveloperOptions: userSettings.app.developerOptionsEnabled,
showAnalyticsSettings: userSettings.app.canPromptForAnalytics,
isBugReportServiceEnabled: isBugReportServiceEnabled,
navigationBarVisibility: isInSecondaryWindow ? .hidden : .automatic,
appHooks: appHooks),
settingsScreenHook: userSession.userHooks.settingsScreenHook,
bindings: .init(userSettings: userSettings)),
mediaProvider: userSession.mediaProvider)
appSettings.developerOptionsEnabledPublisher
userSettings.app.developerOptionsEnabledPublisher
.weakAssign(to: \.state.showDeveloperOptions, on: self)
.store(in: &cancellables)
appSettings.linkNewDeviceEnabledPublisher
userSettings.app.linkNewDeviceEnabledPublisher
.weakAssign(to: \.state.showLinkNewDeviceButton, on: self)
.store(in: &cancellables)
userSettings.app.multiAccountEnabledPublisher
.weakAssign(to: \.state.showAddAccountButton, on: self)
.store(in: &cancellables)
userSession.clientProxy.userProfilePublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.userProfile, on: self)
@@ -73,18 +79,6 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
}
.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 {
if case .success(true) = await userSession.clientProxy.isUserStatusSupported() {
state.showUserStatusInput = true
@@ -98,6 +92,7 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
switch viewAction {
case .close:
actionsSubject.send(.close)
case .userDetails:
actionsSubject.send(.userDetails)
case .userStatus(.pickStatus):
@@ -114,36 +109,45 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
case .userStatus(.cancel):
state.bindings.isPresentingStatusPicker = false
state.bindings.isShowingCustomStatusField = false
case .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case .addAccount:
actionsSubject.send(.addAccount)
case let .manageAccount(url):
actionsSubject.send(.manageAccount(url: url))
case .analytics:
actionsSubject.send(.analytics)
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 .linkNewDevice:
actionsSubject.send(.linkNewDevice)
case .notifications:
actionsSubject.send(.notifications)
case .advancedSettings:
actionsSubject.send(.advancedSettings)
case .labs:
actionsSubject.send(.labs)
case .enableDeveloperOptions:
appSettings.developerOptionsEnabled.toggle()
case .developerOptions:
actionsSubject.send(.developerOptions)
case .secureBackup:
actionsSubject.send(.secureBackup)
case .moderationAndSafety:
actionsSubject.send(.moderationAndSafety)
case .reportBug:
actionsSubject.send(.reportBug)
case .logout:
actionsSubject.send(.logout)
case .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 {
@Bindable var context: SettingsScreenViewModel.Context
private var shouldHideManageAccountSection: Bool {
context.viewState.accountProfileURL == nil &&
!context.viewState.showBlockedUsers &&
!context.viewState.showLinkNewDeviceButton
}
var body: some View {
Form {
userSection
if context.viewState.showUserStatusInput {
userStatusSection
}
context.viewState.appHooks.settingsScreenHook.accountStatusSection {
context.viewState.settingsScreenHook.accountStatusSection {
context.send(viewAction: .userDetails)
}
if !shouldHideManageAccountSection {
manageAccountSection
if context.viewState.showAddAccountButton {
addAccountSection
}
manageMyAppSection
generalSection
accountSettingsSection
signOutSection
if context.viewState.showDeveloperOptions {
developerOptionsSection
}
appSettingsSection
}
.compoundList()
.navigationTitle(L10n.commonSettings)
.navigationBarTitleDisplayMode(.inline)
.toolbarVisibility(context.viewState.navigationBarVisibility, for: .navigationBar)
.toolbar { toolbar }
.onAppear { context.viewState.appHooks.settingsScreenHook.refreshAccountStatus() }
.onAppear { context.viewState.settingsScreenHook.refreshAccountStatus() }
.sheet(isPresented: $context.isPresentingStatusPicker) {
SettingsScreenUserStatusPickerView { action in
context.send(viewAction: .userStatus(action))
@@ -75,15 +61,9 @@ struct SettingsScreen: View {
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(context.viewState.userProfile.displayName ?? "")
if let statusEmoji = context.viewState.userProfile.status.displayed?.emoji {
Text(String(statusEmoji))
}
}
.font(.compound.headingMD)
.foregroundColor(.compound.textPrimary)
Text(context.viewState.userProfile.displayName ?? "")
.font(.compound.headingMD)
.foregroundColor(.compound.textPrimary)
Text(context.viewState.userProfile.id)
.font(.compound.bodySM)
@@ -98,47 +78,27 @@ struct SettingsScreen: View {
.padding(.vertical, 8)
}
})
}
}
private var userStatusSection: some View {
Section {
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
context.send(viewAction: .userStatus(action))
if context.viewState.showUserStatusInput {
SettingsScreenUserStatusRow(mode: context.viewState.userStatusRowMode) { action in
context.send(viewAction: .userStatus(action))
}
}
}
}
private var manageMyAppSection: some View {
private var addAccountSection: some View {
Section {
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
icon: \.notifications),
ListRow(label: .default(title: L10n.screenSettingsAddAccount,
icon: \.plus),
kind: .navigationLink {
context.send(viewAction: .notifications)
context.send(viewAction: .addAccount)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications)
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()
}
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.addAccount)
}
}
private var manageAccountSection: some View {
private var accountSettingsSection: some View {
Section {
if let url = context.viewState.accountProfileURL {
ListRow(label: .default(title: L10n.actionManageAccountAndDevices,
@@ -157,39 +117,30 @@ struct SettingsScreen: View {
})
}
if context.viewState.showBlockedUsers {
ListRow(label: .default(title: L10n.commonBlockedUsers,
icon: \.block),
kind: .navigationLink {
context.send(viewAction: .blockedUsers)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.blockedUsers)
ListRow(label: .default(title: L10n.screenNotificationSettingsTitle,
icon: \.notifications),
kind: .navigationLink {
context.send(viewAction: .notifications)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.notifications)
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,
icon: \.labs),
ListRow(label: .default(title: L10n.commonModerationAndSafety,
icon: \.admin),
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 {
ListRow(label: .default(title: L10n.commonReportAProblem,
icon: \.chatProblem),
@@ -198,15 +149,9 @@ struct SettingsScreen: View {
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.reportBug)
}
if context.viewState.showAnalyticsSettings {
ListRow(label: .default(title: L10n.commonAnalytics,
icon: \.chart),
kind: .navigationLink {
context.send(viewAction: .analytics)
})
.accessibilityIdentifier(A11yIdentifiers.settingsScreen.analytics)
}
} header: {
Text(L10n.commonAccountSettings)
.compoundListSectionHeader()
}
}
@@ -228,21 +173,68 @@ struct SettingsScreen: View {
context.send(viewAction: .deactivateAccount)
})
}
} footer: {
if !context.viewState.showDeveloperOptions {
versionSection
}
}
}
private var developerOptionsSection: some View {
private var appSettingsSection: some View {
Section {
ListRow(label: .default(title: L10n.commonDeveloperOptions,
icon: \.code),
ListRow(label: .default(title: L10n.commonAppearance,
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 {
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: {
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
@available(iOS 26.0, *)
@@ -299,7 +304,7 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: viewModel.context)
}
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1100)
.frame(height: 1300)
.previewLayout(.sizeThatFits)
.previewDisplayName("Default")
@@ -307,19 +312,21 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
SettingsScreen(context: bugReportDisabledViewModel.context)
}
.snapshotPreferences(expect: bugReportDisabledViewModel.context.observe(\.viewState.accountProfileURL).map { $0 != nil })
.frame(height: 1050)
.frame(height: 1250)
.previewLayout(.sizeThatFits)
.previewDisplayName("Bug report disabled")
}
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",
deviceID: "AAAAAAAAAAA",
displayName: "Alice Liddell",
status: .mockFocussing))))
status: .mockFocussing)),
userSettings: userSettings))
return SettingsScreenViewModel(userSession: userSession,
appSettings: .volatile(),
appHooks: AppHooks(),
isBugReportServiceEnabled: isBugReportServiceEnabled,
isInSecondaryWindow: false,
userIndicatorController: UserIndicatorControllerMock())
@@ -18,7 +18,7 @@ struct UserProfileScreenViewState: BindableState {
let userID: String
let isOwnUser: Bool
let isPresentedModally: Bool
let appHooks: AppHooks
let extendedProfileSectionHook: ExtendedProfileSectionHookProtocol
var userProfile: UserProfile?
var isIdentityKnown = false
@@ -34,7 +34,7 @@ class UserProfileScreenViewModel: UserProfileScreenViewModelType, UserProfileScr
let initialViewState = UserProfileScreenViewState(userID: userID,
isOwnUser: userID == userSession.clientProxy.userID,
isPresentedModally: isPresentedModally,
appHooks: appHooks,
extendedProfileSectionHook: userSession.userHooks.extendedProfileSectionHook,
bindings: .init())
super.init(initialViewState: userSession.userHooks.userProfileScreenHook.update(initialViewState),
@@ -16,7 +16,7 @@ struct UserProfileScreen: View {
Form {
headerSection
context.viewState.appHooks.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
context.viewState.extendedProfileSectionHook.extendedProfileSection(userID: context.viewState.userID)
}
.compoundList()
.navigationTitle(L10n.screenRoomMemberDetailsTitle)