Remove the global UserIndicatorController.alertInfo, replacing it with local alertInfo usage. (#5087)

This commit is contained in:
Doug
2026-02-10 17:16:11 +00:00
committed by GitHub
parent b725d8be80
commit 071a1eeb52
21 changed files with 101 additions and 71 deletions
@@ -226,12 +226,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
guard let confirmationParameters = url.confirmationParameters else { guard let confirmationParameters = url.confirmationParameters else {
return false return false
} }
ServiceLocator.shared.userIndicatorController.alertInfo = .init(id: .init(), navigationRootCoordinator.alertInfo = .init(id: .init(),
title: L10n.dialogConfirmLinkTitle, title: L10n.dialogConfirmLinkTitle,
message: L10n.dialogConfirmLinkMessage(confirmationParameters.displayString, message: L10n.dialogConfirmLinkMessage(confirmationParameters.displayString,
confirmationParameters.internalURL.absoluteString), confirmationParameters.internalURL.absoluteString),
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil), primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) }) secondaryButton: .init(title: L10n.actionContinue) { openURLAction(confirmationParameters.internalURL) })
return true return true
} }
@@ -67,6 +67,9 @@ import SwiftUI
overlayModule?.coordinator overlayModule?.coordinator
} }
/// The lowest-level `AlertInfo`, directly available to the root of the app.
var alertInfo: AlertInfo<UUID>?
/// Sets or replaces the presented coordinator /// Sets or replaces the presented coordinator
/// - Parameter coordinator: the coordinator to display /// - Parameter coordinator: the coordinator to display
func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) { func setRootCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
@@ -159,6 +162,7 @@ private struct NavigationRootCoordinatorView: View {
ZStack { ZStack {
rootCoordinator.rootModule?.coordinator?.toPresentable() rootCoordinator.rootModule?.coordinator?.toPresentable()
} }
.alert(item: $rootCoordinator.alertInfo)
.animation(.elementDefault, value: rootCoordinator.rootModule) .animation(.elementDefault, value: rootCoordinator.rootModule)
.sheet(item: $rootCoordinator.sheetModule) { module in .sheet(item: $rootCoordinator.sheetModule) { module in
module.coordinator?.toPresentable() module.coordinator?.toPresentable()
@@ -485,7 +485,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
let secureBackupController = userSession.clientProxy.secureBackupController let secureBackupController = userSession.clientProxy.secureBackupController
guard case let .success(isLastDevice) = await userSession.clientProxy.isOnlyDeviceLeft() else { guard case let .success(isLastDevice) = await userSession.clientProxy.isOnlyDeviceLeft() else {
flowParameters.userIndicatorController.alertInfo = .init(id: .init()) navigationRootCoordinator.alertInfo = .init(id: .init())
return return
} }
@@ -495,26 +495,26 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} }
guard secureBackupController.recoveryState.value == .enabled else { guard secureBackupController.recoveryState.value == .enabled else {
flowParameters.userIndicatorController.alertInfo = .init(id: .init(), navigationRootCoordinator.alertInfo = .init(id: .init(),
title: L10n.screenSignoutRecoveryDisabledTitle, title: L10n.screenSignoutRecoveryDisabledTitle,
message: L10n.screenSignoutRecoveryDisabledSubtitle, message: L10n.screenSignoutRecoveryDisabledSubtitle,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout) self?.actionsSubject.send(.logout)
}, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in
self?.chatsTabFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) self?.chatsTabFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
}) })
return return
} }
guard secureBackupController.keyBackupState.value == .enabled else { guard secureBackupController.keyBackupState.value == .enabled else {
flowParameters.userIndicatorController.alertInfo = .init(id: .init(), navigationRootCoordinator.alertInfo = .init(id: .init(),
title: L10n.screenSignoutKeyBackupDisabledTitle, title: L10n.screenSignoutKeyBackupDisabledTitle,
message: L10n.screenSignoutKeyBackupDisabledSubtitle, message: L10n.screenSignoutKeyBackupDisabledSubtitle,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout) self?.actionsSubject.send(.logout)
}, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in }, secondaryButton: .init(title: L10n.commonSettings, role: .cancel) { [weak self] in
self?.chatsTabFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true) self?.chatsTabFlowCoordinator.handleAppRoute(.chatBackupSettings, animated: true)
}) })
return return
} }
@@ -522,12 +522,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} }
private func logout() { private func logout() {
flowParameters.userIndicatorController.alertInfo = .init(id: .init(), navigationRootCoordinator.alertInfo = .init(id: .init(),
title: L10n.screenSignoutConfirmationDialogTitle, title: L10n.screenSignoutConfirmationDialogTitle,
message: L10n.screenSignoutConfirmationDialogContent, message: L10n.screenSignoutConfirmationDialogContent,
primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in primaryButton: .init(title: L10n.screenSignoutConfirmationDialogSubmit, role: .destructive) { [weak self] in
self?.actionsSubject.send(.logout) self?.actionsSubject.send(.logout)
}) })
} }
private func presentSecureBackupLogoutConfirmationScreen() { private func presentSecureBackupLogoutConfirmationScreen() {
@@ -19524,7 +19524,6 @@ class UserIdentityProxyMock: UserIdentityProxyProtocol, @unchecked Sendable {
} }
class UserIndicatorControllerMock: UserIndicatorControllerProtocol, @unchecked Sendable { class UserIndicatorControllerMock: UserIndicatorControllerProtocol, @unchecked Sendable {
var window: UIWindow? var window: UIWindow?
var alertInfo: AlertInfo<UUID>?
//MARK: - submitIndicator //MARK: - submitIndicator
@@ -32,8 +32,6 @@ class UserIndicatorController: ObservableObject, UserIndicatorControllerProtocol
} }
} }
@Published var alertInfo: AlertInfo<UUID>?
var window: UIWindow? { var window: UIWindow? {
didSet { didSet {
let hostingController = UIHostingController(rootView: UserIndicatorPresenter(userIndicatorController: self).statusBarHidden(ProcessInfo.isRunningUITests)) let hostingController = UIHostingController(rootView: UserIndicatorPresenter(userIndicatorController: self).statusBarHidden(ProcessInfo.isRunningUITests))
@@ -15,7 +15,6 @@ protocol UserIndicatorControllerProtocol: CoordinatorProtocol {
func retractAllIndicators() func retractAllIndicators()
var window: UIWindow? { get set } var window: UIWindow? { get set }
var alertInfo: AlertInfo<UUID>? { get set }
} }
extension UserIndicatorControllerProtocol { extension UserIndicatorControllerProtocol {
@@ -28,6 +28,5 @@ struct UserIndicatorPresenter: View {
} }
} }
} }
.alert(item: $userIndicatorController.alertInfo)
} }
} }
@@ -59,7 +59,7 @@ class OIDCAuthenticationPresenter: NSObject {
let errorDescription = error.map(String.init(describing:)) ?? "Unknown error" let errorDescription = error.map(String.init(describing:)) ?? "Unknown error"
MXLog.error("Missing callback URL from the web authentication session: \(errorDescription)") MXLog.error("Missing callback URL from the web authentication session: \(errorDescription)")
userIndicatorController.alertInfo = AlertInfo(id: UUID()) showFailureIndicator()
await authenticationService.abortOIDCLogin(data: oidcData) await authenticationService.abortOIDCLogin(data: oidcData)
return .failure(.oidcError(.unknown)) return .failure(.oidcError(.unknown))
} }
@@ -76,7 +76,7 @@ class OIDCAuthenticationPresenter: NSObject {
return .failure(.oidcError(.userCancellation)) return .failure(.oidcError(.userCancellation))
case .failure(let error): case .failure(let error):
MXLog.error("Error occurred: \(error)") MXLog.error("Error occurred: \(error)")
userIndicatorController.alertInfo = AlertInfo(id: UUID()) showFailureIndicator()
return .failure(error) return .failure(error)
} }
} }
@@ -85,10 +85,16 @@ class OIDCAuthenticationPresenter: NSObject {
activeSession?.cancel() activeSession?.cancel()
} }
private static let loadingIndicatorID = "\(OIDCAuthenticationPresenter.self)-Loading" private var loadingIndicatorID: String {
"\(Self.self)-Loading"
}
private var failureIndicatorID: String {
"\(Self.self)-Failure"
}
private func startLoading(delay: Duration? = nil) { private func startLoading(delay: Duration? = nil) {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorID, userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorID,
type: .modal, type: .modal,
title: L10n.commonLoading, title: L10n.commonLoading,
persistent: true), persistent: true),
@@ -96,7 +102,14 @@ class OIDCAuthenticationPresenter: NSObject {
} }
private func stopLoading() { private func stopLoading() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorID) userIndicatorController.retractIndicatorWithId(loadingIndicatorID)
}
private func showFailureIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: failureIndicatorID,
type: .toast,
title: L10n.errorUnknown,
iconName: "xmark"))
} }
} }
@@ -10,6 +10,7 @@ import SwiftUI
enum CreateRoomScreenErrorType: Error { enum CreateRoomScreenErrorType: Error {
case failedCreatingRoom case failedCreatingRoom
case failedProcessingMedia
case failedUploadingMedia case failedUploadingMedia
case fileTooLarge case fileTooLarge
case mediaFileError case mediaFileError
@@ -116,7 +116,7 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
do { do {
guard case let .success(maxUploadSize) = await userSession.clientProxy.maxMediaUploadSize else { guard case let .success(maxUploadSize) = await userSession.clientProxy.maxMediaUploadSize else {
MXLog.error("Failed to get max upload size") MXLog.error("Failed to get max upload size")
userIndicatorController.alertInfo = AlertInfo(id: .init()) state.bindings.alertInfo = .init(id: .unknown)
return return
} }
let mediaInfo = try await mediaUploadingPreprocessor.processMedia(at: fileURL, maxUploadSize: maxUploadSize).get() let mediaInfo = try await mediaUploadingPreprocessor.processMedia(at: fileURL, maxUploadSize: maxUploadSize).get()
@@ -128,7 +128,7 @@ class CreateRoomScreenViewModel: CreateRoomScreenViewModelType, CreateRoomScreen
break break
} }
} catch { } catch {
userIndicatorController.alertInfo = AlertInfo(id: .init()) state.bindings.alertInfo = .init(id: .failedProcessingMedia)
} }
hideLoadingIndicator() hideLoadingIndicator()
} }
@@ -105,9 +105,9 @@ class InviteUsersScreenViewModel: InviteUsersScreenViewModelType, InviteUsersScr
return return
} }
userIndicatorController.alertInfo = .init(id: .init(), state.bindings.alertInfo = .init(id: .unknown,
title: L10n.commonUnableToInviteTitle, title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage) message: L10n.commonUnableToInviteMessage)
} }
} }
@@ -69,7 +69,10 @@ struct RoomDetailsEditScreenViewStateBindings {
} }
enum RoomDetailsEditScreenAlertType { enum RoomDetailsEditScreenAlertType {
case failedProcessingMedia
case unsavedChanges case unsavedChanges
case saveError
case unknown
} }
enum RoomDetailsEditScreenViewAction { enum RoomDetailsEditScreenViewAction {
@@ -86,7 +86,7 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else {
MXLog.error("Failed to get max upload size") MXLog.error("Failed to get max upload size")
userIndicatorController.alertInfo = .init(id: .init()) state.bindings.alertInfo = .init(id: .unknown)
return return
} }
let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize)
@@ -95,7 +95,7 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
case .success(.image): case .success(.image):
state.localMedia = try? mediaResult.get() state.localMedia = try? mediaResult.get()
case .failure, .success: case .failure, .success:
userIndicatorController.alertInfo = .init(id: .init()) state.bindings.alertInfo = .init(id: .failedProcessingMedia)
} }
} }
} }
@@ -161,9 +161,9 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
actionsSubject.send(.saveFinished) actionsSubject.send(.saveFinished)
} catch { } catch {
userIndicatorController.alertInfo = .init(id: .init(), state.bindings.alertInfo = .init(id: .saveError,
title: L10n.screenRoomDetailsEditionErrorTitle, title: L10n.screenRoomDetailsEditionErrorTitle,
message: L10n.screenRoomDetailsEditionError) message: L10n.screenRoomDetailsEditionError)
} }
} }
} }
@@ -92,6 +92,11 @@ struct RoomScreenViewState: BindableState {
struct RoomScreenViewStateBindings { struct RoomScreenViewStateBindings {
/// The view model used to present a QuickLook media preview. /// The view model used to present a QuickLook media preview.
var mediaPreviewViewModel: TimelineMediaPreviewViewModel? var mediaPreviewViewModel: TimelineMediaPreviewViewModel?
var alertInfo: AlertInfo<RoomScreenAlertType>?
}
enum RoomScreenAlertType {
case unknown
} }
enum RoomScreenFooterViewAction { enum RoomScreenFooterViewAction {
@@ -286,7 +286,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
showLoadingIndicator() showLoadingIndicator()
if case .failure = await clientProxy.pinUserIdentity(userID) { if case .failure = await clientProxy.pinUserIdentity(userID) {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
} }
} }
@@ -298,7 +298,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
showLoadingIndicator() showLoadingIndicator()
if case .failure = await clientProxy.withdrawUserIdentityVerification(userID) { if case .failure = await clientProxy.withdrawUserIdentityVerification(userID) {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
} }
} }
@@ -67,6 +67,7 @@ struct RoomScreen: View {
.toolbar { toolbar } .toolbar { toolbar }
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator } .overlay { loadingIndicator }
.alert(item: $context.alertInfo)
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.track(screen: .Room) .track(screen: .Room)
.sentryTrace("\(Self.self)") .sentryTrace("\(Self.self)")
@@ -52,7 +52,10 @@ struct UserDetailsEditScreenViewStateBindings {
} }
enum UserDetailsEditScreenAlertType { enum UserDetailsEditScreenAlertType {
case failedProcessingMedia
case unsavedChanges case unsavedChanges
case saveError
case unknown
} }
enum UserDetailsEditScreenViewAction { enum UserDetailsEditScreenViewAction {
@@ -92,7 +92,7 @@ class UserDetailsEditScreenViewModel: UserDetailsEditScreenViewModelType, UserDe
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else {
MXLog.error("Failed to get max upload size") MXLog.error("Failed to get max upload size")
userIndicatorController.alertInfo = .init(id: .init()) state.bindings.alertInfo = .init(id: .unknown)
return return
} }
let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) let mediaResult = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize)
@@ -101,7 +101,7 @@ class UserDetailsEditScreenViewModel: UserDetailsEditScreenViewModelType, UserDe
case .success(.image): case .success(.image):
state.localMedia = try? mediaResult.get() state.localMedia = try? mediaResult.get()
case .failure, .success: case .failure, .success:
userIndicatorController.alertInfo = .init(id: .init()) state.bindings.alertInfo = .init(id: .failedProcessingMedia)
} }
} }
} }
@@ -149,9 +149,9 @@ class UserDetailsEditScreenViewModel: UserDetailsEditScreenViewModelType, UserDe
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
} catch { } catch {
userIndicatorController.alertInfo = .init(id: .init(), state.bindings.alertInfo = .init(id: .saveError,
title: L10n.screenEditProfileErrorTitle, title: L10n.screenEditProfileErrorTitle,
message: L10n.screenEditProfileError) message: L10n.screenEditProfileError)
} }
} }
} }
@@ -199,6 +199,9 @@ enum TimelineAlertInfoType: Hashable {
case sendingFailed case sendingFailed
case encryptionAuthenticity(String) case encryptionAuthenticity(String)
case encryptionForwarder(String) case encryptionForwarder(String)
case inviteAgain
case unableToInvite
case unknown
} }
struct RoomMemberState { struct RoomMemberState {
@@ -579,7 +579,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
shouldShowInviteAlert shouldShowInviteAlert
.sink { [weak self] _ in .sink { [weak self] _ in
self?.showInviteAlert() self?.displayAlert(.inviteAgain)
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@@ -865,19 +865,11 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
// MARK: - Direct chats logics // MARK: - Direct chats logics
private func showInviteAlert() {
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenRoomInviteAgainAlertTitle,
message: L10n.screenRoomInviteAgainAlertMessage,
primaryButton: .init(title: L10n.actionInvite) { [weak self] in self?.inviteOtherDMUserBack() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private let inviteLoadingIndicatorID = UUID().uuidString private let inviteLoadingIndicatorID = UUID().uuidString
private func inviteOtherDMUserBack() { private func inviteOtherDMUserBack() {
guard roomProxy.infoPublisher.value.isUserAloneInDirectRoom else { guard roomProxy.infoPublisher.value.isUserAloneInDirectRoom else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) displayAlert(.unknown)
return return
} }
@@ -892,7 +884,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
members.count == 2, members.count == 2,
let otherPerson = members.first(where: { $0.userID != roomProxy.ownUserID && $0.membership == .leave }) let otherPerson = members.first(where: { $0.userID != roomProxy.ownUserID && $0.membership == .leave })
else { else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) displayAlert(.unknown)
return return
} }
@@ -900,9 +892,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .success: case .success:
break break
case .failure: case .failure:
userIndicatorController.alertInfo = .init(id: .init(), displayAlert(.unableToInvite)
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
} }
} }
} }
@@ -1021,6 +1011,18 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
guard let self else { return } guard let self else { return }
appMediator.open(appSettings.historySharingDetailsURL) appMediator.open(appSettings.historySharingDetailsURL)
}) })
case .inviteAgain:
state.bindings.alertInfo = .init(id: .inviteAgain,
title: L10n.screenRoomInviteAgainAlertTitle,
message: L10n.screenRoomInviteAgainAlertMessage,
primaryButton: .init(title: L10n.actionInvite) { [weak self] in self?.inviteOtherDMUserBack() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .unableToInvite:
state.bindings.alertInfo = .init(id: .unableToInvite,
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
case .unknown:
state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
} }
} }
@@ -137,7 +137,7 @@ class RoomDetailsEditScreenViewModelTests: XCTestCase {
setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin])) setupViewModel(roomProxyConfiguration: .init(name: "Some room", members: [.mockMeAdmin]))
viewModel.didSelectMediaUrl(url: .picturesDirectory) viewModel.didSelectMediaUrl(url: .picturesDirectory)
try? await Task.sleep(for: .milliseconds(100)) try? await Task.sleep(for: .milliseconds(100))
XCTAssertNotNil(userIndicatorController.alertInfo) XCTAssertNotNil(context.alertInfo)
} }
func testDeleteAvatar() { func testDeleteAvatar() {