From d95963cdb2bbb735477d6e9e66c21035cee20a66 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 9 Jan 2025 10:24:14 +0000 Subject: [PATCH] Add sunset banners guiding users to install Element X when registering against a server with MAS. --- Config/BuildSettings.swift | 21 ++++++ Podfile.lock | 10 +-- .../Images.xcassets/Sunset/Contents.json | 6 ++ .../sunset_banner_icon.imageset/Contents.json | 23 ++++++ .../ElementXBannerIcon.png | Bin 0 -> 4269 bytes .../ElementXBannerIcon@2x.png | Bin 0 -> 12961 bytes .../ElementXBannerIcon@3x.png | Bin 0 -> 25785 bytes Riot/Assets/en.lproj/Vector.strings | 8 +++ Riot/Generated/Images.swift | 1 + Riot/Generated/Strings.swift | 24 +++++++ .../AuthenticationCoordinator.swift | 11 +-- .../Common/AuthenticationModels.swift | 3 + .../MatrixSDK/AuthenticationService.swift | 14 +++- .../Service/MatrixSDK/LoginModels.swift | 19 +++-- .../AuthenticationRegistrationModels.swift | 12 +++- .../AuthenticationRegistrationViewModel.swift | 14 +++- ...uthenticationRegistrationCoordinator.swift | 27 +++++-- ...uthenticationRegistrationScreenState.swift | 20 ++++-- .../AuthenticationRegistrationUITests.swift | 37 +++++++++- ...enticationRegistrationViewModelTests.swift | 28 +++++++- .../AuthenticationRegistrationScreen.swift | 19 +++++ .../AuthenticationServerSelectionModels.swift | 15 +++- ...thenticationServerSelectionViewModel.swift | 12 +++- ...enticationServerSelectionCoordinator.swift | 32 +++++++-- ...enticationServerSelectionScreenState.swift | 6 ++ ...AuthenticationServerSelectionUITests.swift | 14 ++++ ...icationServerSelectionViewModelTests.swift | 30 +++++++- .../AuthenticationServerSelectionScreen.swift | 30 +++++--- .../Common/Sunset/SunsetDownloadBanner.swift | 66 ++++++++++++++++++ .../Sunset/SunsetOIDCRegistrationBanner.swift | 52 ++++++++++++++ changelog.d/7889.api | 1 + changelog.d/7889.change | 1 + 32 files changed, 497 insertions(+), 59 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Sunset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon.png create mode 100644 Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@2x.png create mode 100644 Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@3x.png create mode 100644 RiotSwiftUI/Modules/Common/Sunset/SunsetDownloadBanner.swift create mode 100644 RiotSwiftUI/Modules/Common/Sunset/SunsetOIDCRegistrationBanner.swift create mode 100644 changelog.d/7889.api create mode 100644 changelog.d/7889.change diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index f045de597d..ee01e686a7 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -441,4 +441,25 @@ final class BuildSettings: NSObject { // MARK: - Alerts static let showUnverifiedSessionsAlert = true + + // MARK: - Sunset + + /// Meta data about the app that will replaces this one with Matrix 2.0 support. + struct ReplacementApp { + /// The app's display name, used in marketing banners. + let name = "Element X" + /// A link that will be opened to tell the user more about the new app, Matrix 2.0 and the migration. + let learnMoreURL = URL(string: "https://element.io")! // FIXME: This isn't the final URL. + /// The app's iTunes/product ID, used to show the App Store page in-app. + let productID = "1631335820" + /// A fallback URL that will be opened if there are any issues showing the App Store page in-app. + let appStoreURL = URL(string: "https://apps.apple.com/app/element-x-secure-chat-call/id1631335820")! + } + + /// Information about the Matrix 2.0 compatible app that will replace this one in the future. + /// + /// The presence of this setting acts as a feature flag to show marketing banners for the app + /// when it is detected that the homeserver is running Matrix 2.0. Set this to `nil` until you + /// are ready to migrate your users. + static let replacementApp: ReplacementApp? = .init() } diff --git a/Podfile.lock b/Podfile.lock index 34baf860f3..376efc3eb7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -39,16 +39,16 @@ PODS: - LoggerAPI (1.9.200): - Logging (~> 1.1) - Logging (1.4.0) - - MatrixSDK (0.27.16): - - MatrixSDK/Core (= 0.27.16) - - MatrixSDK/Core (0.27.16): + - MatrixSDK (0.27.17): + - MatrixSDK/Core (= 0.27.17) + - MatrixSDK/Core (0.27.17): - AFNetworking (~> 4.0.0) - GZIP (~> 1.3.0) - libbase58 (~> 0.1.4) - MatrixSDKCrypto (= 0.4.3) - Realm (= 10.27.0) - SwiftyBeaver (= 1.9.5) - - MatrixSDK/JingleCallStack (0.27.16): + - MatrixSDK/JingleCallStack (0.27.17): - JitsiMeetSDKLite (= 8.1.2-lite) - MatrixSDK/Core - MatrixSDKCrypto (0.4.3) @@ -179,7 +179,7 @@ SPEC CHECKSUMS: libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d Logging: beeb016c9c80cf77042d62e83495816847ef108b - MatrixSDK: ce8f2cec670c2212144a129cc617d4144c89b97f + MatrixSDK: e3096b0b47f8a0bde6ae3f614f9c49e7e92b03ea MatrixSDKCrypto: 27bee960e0e8b3a3039f3f3e93dd2ec88299c77e ReadMoreTextView: 19147adf93abce6d7271e14031a00303fe28720d Realm: 9ca328bd7e700cc19703799785e37f77d1a130f2 diff --git a/Riot/Assets/Images.xcassets/Sunset/Contents.json b/Riot/Assets/Images.xcassets/Sunset/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Sunset/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/Contents.json new file mode 100644 index 0000000000..549a495b60 --- /dev/null +++ b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ElementXBannerIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ElementXBannerIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ElementXBannerIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon.png b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a12a28eb78329a9fffe493bc394992253c6fb8a GIT binary patch literal 4269 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 String { + return VectorL10n.tr("Vector", "sunset_delegated_oidc_registration_not_supported_message", p1, p2) + } + /// You can no longer create an account with %1$@ using this app + public static func sunsetDelegatedOidcRegistrationNotSupportedTitle(_ p1: String) -> String { + return VectorL10n.tr("Vector", "sunset_delegated_oidc_registration_not_supported_title", p1) + } + /// Learn more + public static var sunsetDownloadBannerLearnMore: String { + return VectorL10n.tr("Vector", "sunset_download_banner_learn_more") + } + /// Faster, more secure, and packed with powerful collaboration tools. + public static var sunsetDownloadBannerMessage: String { + return VectorL10n.tr("Vector", "sunset_download_banner_message") + } + /// Download %1$@ + public static func sunsetDownloadBannerTitle(_ p1: String) -> String { + return VectorL10n.tr("Vector", "sunset_download_banner_title", p1) + } /// Switch public static var `switch`: String { return VectorL10n.tr("Vector", "switch") diff --git a/Riot/Modules/Authentication/AuthenticationCoordinator.swift b/Riot/Modules/Authentication/AuthenticationCoordinator.swift index 357a7a54a8..74a0c3cf46 100644 --- a/Riot/Modules/Authentication/AuthenticationCoordinator.swift +++ b/Riot/Modules/Authentication/AuthenticationCoordinator.swift @@ -137,9 +137,13 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc return } + var showReplacementAppBanner = false do { // Start the flow (if homeserverAddress is nil, the default server will be used). try await authenticationService.startFlow(flow) + } catch RegistrationError.delegatedOIDCRequiresReplacementApp where BuildSettings.replacementApp != nil { + // The flow can continue, allowing the Registration Screen to display the banner. + showReplacementAppBanner = true } catch { MXLog.error("[AuthenticationCoordinator] start: Failed to start, showing server selection.") showServerSelectionScreen(for: flow) @@ -151,7 +155,7 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc if authenticationService.state.homeserver.needsRegistrationFallback { showFallback(for: flow) } else { - showRegistrationScreen() + showRegistrationScreen(showReplacementAppBanner: showReplacementAppBanner) } case .login: if authenticationService.state.homeserver.needsLoginFallback { @@ -354,13 +358,12 @@ final class AuthenticationCoordinator: NSObject, AuthenticationCoordinatorProtoc // MARK: - Registration /// Shows the registration screen. - @MainActor private func showRegistrationScreen() { + @MainActor private func showRegistrationScreen(showReplacementAppBanner: Bool = false) { MXLog.debug("[AuthenticationCoordinator] showRegistrationScreen") let homeserver = authenticationService.state.homeserver let parameters = AuthenticationRegistrationCoordinatorParameters(navigationRouter: navigationRouter, authenticationService: authenticationService, - registrationFlow: homeserver.registrationFlow, - loginMode: homeserver.preferredLoginMode) + showReplacementAppBanner: showReplacementAppBanner) let coordinator = AuthenticationRegistrationCoordinator(parameters: parameters) coordinator.callback = { [weak self, weak coordinator] result in guard let self = self, let coordinator = coordinator else { return } diff --git a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift index b15b5d7114..00faa8f94f 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/AuthenticationModels.swift @@ -42,6 +42,7 @@ enum AuthenticationError: String, LocalizedError { /// Errors that can be thrown from `RegistrationWizard` enum RegistrationError: String, LocalizedError { case registrationDisabled + case delegatedOIDCRequiresReplacementApp case createAccountNotCalled case missingThreePIDData case missingThreePIDURL @@ -58,6 +59,8 @@ enum RegistrationError: String, LocalizedError { return VectorL10n.authMsisdnValidationError case .invalidPhoneNumber: return VectorL10n.authenticationVerifyMsisdnInvalidPhoneNumber + case .delegatedOIDCRequiresReplacementApp: + return VectorL10n.sunsetDelegatedOidcRegistrationNotSupportedGenericError default: return VectorL10n.errorCommonMessage } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 47910e307d..b3a63ba0d2 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -148,8 +148,13 @@ class AuthenticationService: NSObject { self.registrationWizard = registrationWizard } catch { guard homeserver.preferredLoginMode.hasSSO, error as? RegistrationError == .registrationDisabled else { - throw error + if homeserver.preferredLoginMode.providesDelegatedOIDCCompatibility { + throw RegistrationError.delegatedOIDCRequiresReplacementApp + } else { + throw error + } } + // Continue without throwing when registration is disabled but SSO is available. } } @@ -281,10 +286,13 @@ class AuthenticationService: NSObject { // Get the login flow let loginFlowResponse = try await client.getLoginSession() - let identityProviders = loginFlowResponse.flows?.compactMap { $0 as? MXLoginSSOFlow }.first?.identityProviders ?? [] + let firstSSOFlow = loginFlowResponse.flows?.compactMap { $0 as? MXLoginSSOFlow }.first + let identityProviders = firstSSOFlow?.identityProviders ?? [] + let providesDelegatedOIDCCompatibility = firstSSOFlow?.delegatedOIDCCompatibility ?? false return LoginFlowResult(supportedLoginTypes: loginFlowResponse.flows?.compactMap { $0 } ?? [], ssoIdentityProviders: identityProviders.sorted { $0.name < $1.name }.map(\.ssoIdentityProvider), - homeserverAddress: client.homeserver) + homeserverAddress: client.homeserver, + providesDelegatedOIDCCompatibility: providesDelegatedOIDCCompatibility) } /// Perform a well-known request on the specified homeserver URL. diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift index 50b7f94143..81803192c2 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginModels.swift @@ -12,13 +12,15 @@ struct LoginFlowResult { let supportedLoginTypes: [MXLoginFlow] let ssoIdentityProviders: [SSOIdentityProvider] let homeserverAddress: String + let providesDelegatedOIDCCompatibility: Bool var loginMode: LoginMode { if supportedLoginTypes.contains(where: { $0.type == kMXLoginFlowTypeSSO }), - supportedLoginTypes.contains(where: { $0.type == kMXLoginFlowTypePassword }) { + supportedLoginTypes.contains(where: { $0.type == kMXLoginFlowTypePassword }), + !providesDelegatedOIDCCompatibility { return .ssoAndPassword(ssoIdentityProviders: ssoIdentityProviders) } else if supportedLoginTypes.contains(where: { $0.type == kMXLoginFlowTypeSSO }) { - return .sso(ssoIdentityProviders: ssoIdentityProviders) + return .sso(ssoIdentityProviders: ssoIdentityProviders, providesDelegatedOIDCCompatibility: providesDelegatedOIDCCompatibility) } else if supportedLoginTypes.contains(where: { $0.type == kMXLoginFlowTypePassword }) { return .password } else { @@ -34,7 +36,7 @@ enum LoginMode { /// The homeserver supports login with a password. case password /// The homeserver supports login via one or more SSO providers. - case sso(ssoIdentityProviders: [SSOIdentityProvider]) + case sso(ssoIdentityProviders: [SSOIdentityProvider], providesDelegatedOIDCCompatibility: Bool) /// The homeserver supports login with either a password or via an SSO provider. case ssoAndPassword(ssoIdentityProviders: [SSOIdentityProvider]) /// The homeserver only allows login with unsupported mechanisms. Use fallback instead. @@ -42,7 +44,7 @@ enum LoginMode { var ssoIdentityProviders: [SSOIdentityProvider]? { switch self { - case .sso(let ssoIdentityProviders), .ssoAndPassword(let ssoIdentityProviders): + case .sso(let ssoIdentityProviders, _), .ssoAndPassword(let ssoIdentityProviders): // Provide a backup for homeservers that support SSO but don't offer any identity providers // https://spec.matrix.org/latest/client-server-api/#client-login-via-sso return ssoIdentityProviders.count > 0 ? ssoIdentityProviders : [SSOIdentityProvider(id: "", name: "SSO", brand: nil, iconURL: nil)] @@ -60,6 +62,15 @@ enum LoginMode { } } + var providesDelegatedOIDCCompatibility: Bool { + switch self { + case .sso(_, providesDelegatedOIDCCompatibility: true): + return true + default: + return false + } + } + var supportsPasswordFlow: Bool { switch self { case .password, .ssoAndPassword: diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift index 1c7776687b..651f1c10b6 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationModels.swift @@ -20,6 +20,8 @@ enum AuthenticationRegistrationViewModelResult: CustomStringConvertible { case continueWithSSO(SSOIdentityProvider) /// Continue using a fallback case fallback + /// Show the app store page for the replacement app. + case downloadReplacementApp(BuildSettings.ReplacementApp) /// A string representation of the result, ignoring any associated values that could leak PII. var description: String { @@ -34,6 +36,8 @@ enum AuthenticationRegistrationViewModelResult: CustomStringConvertible { return "continueWithSSO: \(provider)" case .fallback: return "fallback" + case .downloadReplacementApp: + return "downloadReplacementApp" } } } @@ -52,6 +56,8 @@ struct AuthenticationRegistrationViewState: BindableState { /// Data about the selected homeserver. var homeserver: AuthenticationHomeserverViewData + + var showReplacementAppBanner: Bool /// Whether a new homeserver is currently being loaded. var isLoading = false /// View state that can be bound to from SwiftUI. @@ -85,7 +91,7 @@ struct AuthenticationRegistrationViewState: BindableState { /// Whether to show any SSO buttons. var showSSOButtons: Bool { - !homeserver.ssoIdentityProviders.isEmpty + !homeserver.ssoIdentityProviders.isEmpty && !showReplacementAppBanner } /// Whether the current `username` is invalid. @@ -137,6 +143,8 @@ enum AuthenticationRegistrationViewAction { case continueWithSSO(SSOIdentityProvider) /// Continue using the fallback page case fallback + /// Show the app store page for the replacement app. + case downloadReplacementApp(BuildSettings.ReplacementApp) } enum AuthenticationRegistrationErrorType: Hashable { @@ -151,6 +159,8 @@ enum AuthenticationRegistrationErrorType: Hashable { case invalidResponse /// The homeserver doesn't support registration. case registrationDisabled + /// The app doesn't support registration with this homeserver. + case registrationNotSupported /// An unknown error occurred. case unknown } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift index 753114d60a..3baaca2e44 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/AuthenticationRegistrationViewModel.swift @@ -19,9 +19,9 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy // MARK: - Setup - init(homeserver: AuthenticationHomeserverViewData) { + init(homeserver: AuthenticationHomeserverViewData, showReplacementAppBanner: Bool) { let bindings = AuthenticationRegistrationBindings() - let viewState = AuthenticationRegistrationViewState(homeserver: homeserver, bindings: bindings) + let viewState = AuthenticationRegistrationViewState(homeserver: homeserver, showReplacementAppBanner: showReplacementAppBanner, bindings: bindings) super.init(initialViewState: viewState) } @@ -44,6 +44,8 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy Task { await callback?(.continueWithSSO(provider)) } case .fallback: Task { await callback?(.fallback) } + case .downloadReplacementApp(let replacementApp): + Task { await callback?(.downloadReplacementApp(replacementApp)) } } } @@ -54,6 +56,10 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy @MainActor func update(homeserver: AuthenticationHomeserverViewData) { state.homeserver = homeserver + + // Only the initial homeserver will ever need this, it isn't possible to update the + // server to another one that requires the replacement app as the selection will fail. + state.showReplacementAppBanner = false } @MainActor func update(username: String) { @@ -82,6 +88,10 @@ class AuthenticationRegistrationViewModel: AuthenticationRegistrationViewModelTy state.bindings.alertInfo = AlertInfo(id: type, title: VectorL10n.error, message: VectorL10n.loginErrorRegistrationIsNotSupported) + case .registrationNotSupported: + state.bindings.alertInfo = AlertInfo(id: type, + title: VectorL10n.error, + message: VectorL10n.sunsetDelegatedOidcRegistrationNotSupportedGenericError) case .unknown: state.bindings.alertInfo = AlertInfo(id: type) } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift index 6f9051b583..2fa69bf4e4 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Coordinator/AuthenticationRegistrationCoordinator.swift @@ -7,15 +7,14 @@ import CommonKit import MatrixSDK +import StoreKit import SwiftUI struct AuthenticationRegistrationCoordinatorParameters { let navigationRouter: NavigationRouterType let authenticationService: AuthenticationService - /// The registration flow that is available for the chosen server. - let registrationFlow: RegistrationResult? - /// The login mode to allow SSO buttons to be shown when available. - let loginMode: LoginMode + /// Whether the authentication service is configured with a server uses MAS and so Element X should be used for registration instead. + let showReplacementAppBanner: Bool } enum AuthenticationRegistrationCoordinatorResult: CustomStringConvertible { @@ -75,7 +74,7 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { self.parameters = parameters let homeserver = parameters.authenticationService.state.homeserver - let viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver.viewData) + let viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver.viewData, showReplacementAppBanner: parameters.showReplacementAppBanner) authenticationRegistrationViewModel = viewModel let view = AuthenticationRegistrationScreen(viewModel: viewModel.context) @@ -116,6 +115,8 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { self.callback?(.continueWithSSO(provider)) case .fallback: self.callback?(.fallback) + case .downloadReplacementApp(let replacementApp): + Task { await self.showReplacementAppStorePage(replacementApp) } } } } @@ -242,6 +243,9 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { switch registrationError { case .registrationDisabled: authenticationRegistrationViewModel.displayError(.registrationDisabled) + case .delegatedOIDCRequiresReplacementApp: + // Edge case, is only shown in the user enters @alice:myserver.com to register directly on myserver.com + authenticationRegistrationViewModel.displayError(.registrationNotSupported) case .createAccountNotCalled, .missingThreePIDData, .missingThreePIDURL, .threePIDClientFailure, .threePIDValidationFailure, .waitingForThreePIDValidation, .invalidPhoneNumber: // Shouldn't happen at this stage authenticationRegistrationViewModel.displayError(.unknown) @@ -289,4 +293,17 @@ final class AuthenticationRegistrationCoordinator: Coordinator, Presentable { let homeserver = authenticationService.state.homeserver authenticationRegistrationViewModel.update(homeserver: homeserver.viewData) } + + /// Presets the App Store page for the replacement app as a sheet. + @MainActor private func showReplacementAppStorePage(_ replacementApp: BuildSettings.ReplacementApp) async { + do { + let storeViewController = SKStoreProductViewController() + try await storeViewController.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: replacementApp.productID]) + authenticationRegistrationHostingController.present(storeViewController, animated: true) + } catch { + // Open the app store URL outside of the app as a fallback. + MXLog.warning("Unable to open the in-app store product page: \(error)") + await UIApplication.shared.open(replacementApp.appStoreURL) + } + } } diff --git a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift index 8aae52ecaf..75a6375835 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/MockAuthenticationRegistrationScreenState.swift @@ -20,6 +20,7 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { case passwordWithUsernameError case ssoOnly case fallback + case mas /// The associated screen var screenType: Any.Type { @@ -31,22 +32,29 @@ enum MockAuthenticationRegistrationScreenState: MockScreenState, CaseIterable { let viewModel: AuthenticationRegistrationViewModel switch self { case .matrixDotOrg: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockMatrixDotOrg) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockMatrixDotOrg, showReplacementAppBanner: false) case .passwordOnly: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer, showReplacementAppBanner: false) case .passwordWithCredentials: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer, showReplacementAppBanner: false) viewModel.context.username = "alice" viewModel.context.password = "password" Task { await viewModel.confirmUsernameAvailability("alice") } case .passwordWithUsernameError: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockBasicServer, showReplacementAppBanner: false) viewModel.state.hasEditedUsername = true Task { await viewModel.displayError(.usernameUnavailable(VectorL10n.authInvalidUserName)) } case .ssoOnly: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockEnterpriseSSO, showReplacementAppBanner: false) case .fallback: - viewModel = AuthenticationRegistrationViewModel(homeserver: .mockFallback) + viewModel = AuthenticationRegistrationViewModel(homeserver: .mockFallback, showReplacementAppBanner: false) + case .mas: + viewModel = AuthenticationRegistrationViewModel(homeserver: .init(address: "beta.matrix.org", + showLoginForm: false, + showRegistrationForm: false, + showQRLogin: false, + ssoIdentityProviders: []), // The initial discovery failed so the OIDC provider is not known. + showReplacementAppBanner: true) } // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift index 70ac392546..a237d59247 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/UI/AuthenticationRegistrationUITests.swift @@ -15,6 +15,7 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "matrix.org" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreShown(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsHidden(for: state) validateUnknownUsernameAvailability(for: state) @@ -27,6 +28,7 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "a password only server" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsHidden(for: state) validateNextButtonIsDisabled(for: state) @@ -41,6 +43,7 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "a password only server with credentials entered" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsHidden(for: state) validateNextButtonIsEnabled(for: state) @@ -55,6 +58,7 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "a password only server with an invalid username" validateRegistrationFormIsVisible(for: state) validateSSOButtonsAreHidden(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsHidden(for: state) validateNextButtonIsDisabled(for: state) @@ -67,6 +71,7 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "an SSO only server" validateRegistrationFormIsHidden(for: state) validateSSOButtonsAreShown(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsHidden(for: state) } @@ -76,9 +81,20 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { let state = "fallback" validateRegistrationFormIsHidden(for: state) validateSSOButtonsAreHidden(for: state) + validateSunsetBannersAreHidden(for: state) validateFallbackButtonIsShown(for: state) } + func testSunsetBanner() { + app.goToScreenWithIdentifier(MockAuthenticationRegistrationScreenState.mas.title) + + let state = "mas" + validateRegistrationFormIsHidden(for: state) + validateSSOButtonsAreHidden(for: state) + validateSunsetBannersAreShown(for: state) + validateFallbackButtonIsShown(for: state, isEnabled: false) + } + /// Checks that the username and password text fields are shown along with the next button. func validateRegistrationFormIsVisible(for state: String) { let usernameTextField = app.textFields.element @@ -108,12 +124,27 @@ class AuthenticationRegistrationUITests: MockScreenTestCase { XCTAssertFalse(fallbackButton.exists, "The fallback button should not be shown for \(state).") } - /// Checks that the fallback button is hidden. - func validateFallbackButtonIsShown(for state: String) { + /// Checks that the fallback button is shown. + func validateFallbackButtonIsShown(for state: String, isEnabled: Bool = true) { let fallbackButton = app.buttons["fallbackButton"] XCTAssertTrue(fallbackButton.exists, "The fallback button should be shown for \(state).") - XCTAssertTrue(fallbackButton.isEnabled, "The fallback button should be enabled for \(state).") + XCTAssertEqual(fallbackButton.isEnabled, isEnabled, "The fallback button should be \(isEnabled ? "enabled" : "disabled") for \(state).") + } + + /// Checks that the sunset banners are hidden. + func validateSunsetBannersAreHidden(for state: String) { + let downloadBanner = app.buttons["sunsetBanners"] + + XCTAssertFalse(downloadBanner.exists, "The sunset banners should not be shown for \(state).") + } + + /// Checks that the sunset banners are shown. + func validateSunsetBannersAreShown(for state: String) { + let downloadBanner = app.buttons["sunsetBanners"] + + XCTAssertTrue(downloadBanner.exists, "The sunset banners should be shown for \(state).") + XCTAssertTrue(downloadBanner.isEnabled, "The sunset banners should be enabled for \(state).") } /// Checks that there is at least one SSO button shown on the screen. diff --git a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift index 3cd212a847..4c0e5911fd 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/Test/Unit/AuthenticationRegistrationViewModelTests.swift @@ -13,11 +13,10 @@ import XCTest @MainActor class AuthenticationRegistrationViewModelTests: XCTestCase { let defaultHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg var viewModel: AuthenticationRegistrationViewModelProtocol! - var context: AuthenticationRegistrationViewModelType.Context! + var context: AuthenticationRegistrationViewModelType.Context { viewModel.context } @MainActor override func setUp() async throws { - viewModel = AuthenticationRegistrationViewModel(homeserver: defaultHomeserver) - context = viewModel.context + viewModel = AuthenticationRegistrationViewModel(homeserver: defaultHomeserver, showReplacementAppBanner: false) } func testMatrixDotOrg() { @@ -27,6 +26,7 @@ import XCTest // Then the view state should contain a homeserver that matches matrix.org and shows SSO buttons. XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.") XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should be shown.") + XCTAssertFalse(context.viewState.showReplacementAppBanner, "The sunset banner should not be shown.") } func testBasicServer() { @@ -262,6 +262,28 @@ import XCTest // Then the view state should reflect that the homeserver is loading. XCTAssertEqual(context.username, localPart, "The username should match the value passed to the update method.") } + + func testSunsetBanner() async { + // Given a view model configured with a default homeserver that requires MAS (and the sunset banner). + let homeserver = AuthenticationHomeserverViewData(address: "beta.matrix.org", + showLoginForm: false, + showRegistrationForm: false, + showQRLogin: false, + ssoIdentityProviders: []) // The initial discovery would have failed so the OIDC provider is not known. + viewModel = AuthenticationRegistrationViewModel(homeserver: homeserver, showReplacementAppBanner: true) + XCTAssertEqual(context.viewState.homeserver, homeserver, "The homeserver data should match the original.") + XCTAssertTrue(context.viewState.showReplacementAppBanner, "The sunset banner should be shown.") + XCTAssertFalse(context.viewState.showSSOButtons, "The SSO buttons should not be shown.") + + // When selecting another server that doesn't require MAS. + let legacyHomeserver = AuthenticationHomeserverViewData.mockMatrixDotOrg + viewModel.update(homeserver: legacyHomeserver) + + // Then the banner should be removed and registration should be possible. + XCTAssertEqual(context.viewState.homeserver, legacyHomeserver, "The homeserver data should match the updated server.") + XCTAssertFalse(context.viewState.showReplacementAppBanner, "The sunset banner should no longer be visible.") + XCTAssertTrue(context.viewState.showSSOButtons, "The SSO buttons should now be visible.") + } } extension AuthenticationRegistrationViewState.UsernameAvailability: Equatable { diff --git a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift index abca6dc3dc..512696cc02 100644 --- a/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/Registration/View/AuthenticationRegistrationScreen.swift @@ -50,9 +50,12 @@ struct AuthenticationRegistrationScreen: View { ssoButtons .padding(.top, 16) } + + sunsetBanners if !viewModel.viewState.homeserver.showRegistrationForm, !viewModel.viewState.showSSOButtons { fallbackButton + .disabled(viewModel.viewState.showReplacementAppBanner) // This button conveniently shows in the EX banner state, so use it as the disabled button. } } .readableFrame() @@ -134,6 +137,22 @@ struct AuthenticationRegistrationScreen: View { } } } + + @ViewBuilder + var sunsetBanners: some View { + if viewModel.viewState.showReplacementAppBanner, let replacementApp = BuildSettings.replacementApp { + VStack(spacing: 20) { + SunsetOIDCRegistrationBanner(homeserverAddress: viewModel.viewState.homeserver.address, + replacementApp: replacementApp) + + SunsetDownloadBanner(replacementApp: replacementApp) { + viewModel.send(viewAction: .downloadReplacementApp(replacementApp)) + } + } + .padding(.bottom, 20) + .accessibilityIdentifier("sunsetBanners") + } + } /// A fallback button that can be used for login. var fallbackButton: some View { diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift index eddcce981d..4ddd829ff9 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionModels.swift @@ -14,15 +14,22 @@ enum AuthenticationServerSelectionViewModelResult { case confirm(homeserverAddress: String) /// Dismiss the view without using the entered address. case dismiss + /// Show the app store page for the replacement app. + case downloadReplacementApp(BuildSettings.ReplacementApp) } // MARK: View struct AuthenticationServerSelectionViewState: BindableState { + enum FooterError: Equatable { + case message(String) + case sunsetBanner + } + /// View state that can be bound to from SwiftUI. var bindings: AuthenticationServerSelectionBindings /// An error message to be shown in the text field footer. - var footerErrorMessage: String? + var footerError: FooterError? /// The flow that the screen is being used for. let flow: AuthenticationFlow /// Whether the screen is presented modally or within a navigation stack. @@ -43,7 +50,7 @@ struct AuthenticationServerSelectionViewState: BindableState { /// The text field is showing an error. var isShowingFooterError: Bool { - footerErrorMessage != nil + footerError != nil } /// Whether it is possible to continue when tapping the confirmation button. @@ -66,6 +73,8 @@ enum AuthenticationServerSelectionViewAction { case dismiss /// Clear any errors shown in the text field footer. case clearFooterError + /// Show the app store page for the replacement app. + case downloadReplacementApp(BuildSettings.ReplacementApp) } enum AuthenticationServerSelectionErrorType: Hashable { @@ -73,4 +82,6 @@ enum AuthenticationServerSelectionErrorType: Hashable { case footerMessage(String) /// An error occurred when trying to open the EMS link case openURLAlert + /// An error message shown alongside a marketing banner to download the replacement app. + case requiresReplacementApp } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift index 89c329a3c1..703ea67f8a 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/AuthenticationServerSelectionViewModel.swift @@ -37,6 +37,8 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM Task { await callback?(.dismiss) } case .clearFooterError: Task { await clearFooterError() } + case .downloadReplacementApp(let replacementApp): + Task { await callback?(.downloadReplacementApp(replacementApp)) } } } @@ -44,10 +46,14 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM switch type { case .footerMessage(let message): withAnimation { - state.footerErrorMessage = message + state.footerError = .message(message) } case .openURLAlert: state.bindings.alertInfo = AlertInfo(id: .openURLAlert, title: VectorL10n.roomMessageUnableOpenLinkErrorMessage) + case .requiresReplacementApp: + withAnimation { + state.footerError = .sunsetBanner + } } } @@ -55,7 +61,7 @@ class AuthenticationServerSelectionViewModel: AuthenticationServerSelectionViewM /// Clear any errors shown in the text field footer. @MainActor private func clearFooterError() { - guard state.footerErrorMessage != nil else { return } - withAnimation { state.footerErrorMessage = nil } + guard state.footerError != nil else { return } + withAnimation { state.footerError = nil } } } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift index c5f15ecbb3..7ea5259e56 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Coordinator/AuthenticationServerSelectionCoordinator.swift @@ -6,6 +6,7 @@ // import CommonKit +import StoreKit import SwiftUI struct AuthenticationServerSelectionCoordinatorParameters { @@ -90,6 +91,8 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { self.useHomeserver(homeserverAddress) case .dismiss: self.callback?(.dismiss) + case .downloadReplacementApp(let replacementApp): + Task { await self.showReplacementAppStorePage(replacementApp) } } } } @@ -119,17 +122,32 @@ final class AuthenticationServerSelectionCoordinator: Coordinator, Presentable { stopLoading() callback?(.updated) + } catch RegistrationError.delegatedOIDCRequiresReplacementApp where BuildSettings.replacementApp != nil { + stopLoading() + authenticationServerSelectionViewModel.displayError(.requiresReplacementApp) + } catch let registrationError as RegistrationError { + stopLoading() + authenticationServerSelectionViewModel.displayError(.footerMessage(registrationError.localizedDescription)) } catch { stopLoading() - if let error = error as? RegistrationError { - authenticationServerSelectionViewModel.displayError(.footerMessage(error.localizedDescription)) - } else { - // Show the MXError message if possible otherwise use a generic server error - let message = MXError(nsError: error)?.error ?? VectorL10n.authenticationServerSelectionGenericError - authenticationServerSelectionViewModel.displayError(.footerMessage(message)) - } + // Show the MXError message if possible otherwise use a generic server error + let message = MXError(nsError: error)?.error ?? VectorL10n.authenticationServerSelectionGenericError + authenticationServerSelectionViewModel.displayError(.footerMessage(message)) } } } + + /// Presets the App Store page for the replacement app as a sheet. + @MainActor private func showReplacementAppStorePage(_ replacementApp: BuildSettings.ReplacementApp) async { + do { + let storeViewController = SKStoreProductViewController() + try await storeViewController.loadProduct(withParameters: [SKStoreProductParameterITunesItemIdentifier: replacementApp.productID]) + authenticationServerSelectionHostingController.present(storeViewController, animated: true) + } catch { + // Open the app store URL outside of the app as a fallback. + MXLog.warning("Unable to open the in-app store product page: \(error)") + await UIApplication.shared.open(replacementApp.appStoreURL) + } + } } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift index c33e4587f2..c034261ec4 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/MockAuthenticationServerSelectionScreenState.swift @@ -19,6 +19,7 @@ enum MockAuthenticationServerSelectionScreenState: MockScreenState, CaseIterable case invalidAddress case login case nonModal + case mas /// The associated screen var screenType: Any.Type { @@ -50,6 +51,11 @@ enum MockAuthenticationServerSelectionScreenState: MockScreenState, CaseIterable viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "matrix.org", flow: .register, hasModalPresentation: false) + case .mas: + viewModel = AuthenticationServerSelectionViewModel(homeserverAddress: "beta.matrix.org", + flow: .register, + hasModalPresentation: false) + Task { await viewModel.displayError(.requiresReplacementApp) } } // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift index 868608ca78..4f1d49fd99 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/UI/AuthenticationServerSelectionUITests.swift @@ -30,6 +30,9 @@ class AuthenticationServerSelectionUITests: MockScreenTestCase { let dismissButton = app.buttons["dismissButton"] XCTAssertTrue(dismissButton.exists, "The dismiss button should be shown during modal presentation.") + + let downloadBanner = app.buttons["sunsetBanners"] + XCTAssertFalse(downloadBanner.exists, "The sunset banners should not be shown when registering against a legacy homeserver.") } func testLoginState() { @@ -76,4 +79,15 @@ class AuthenticationServerSelectionUITests: MockScreenTestCase { let confirmButton = app.buttons["confirmButton"] XCTAssertEqual(confirmButton.label, VectorL10n.next, "The confirm button should say Next when not in modal presentation.") } + + func testSunsetBanners() { + app.goToScreenWithIdentifier(MockAuthenticationServerSelectionScreenState.mas.title) + + let downloadBanner = app.buttons["sunsetBanners"] + XCTAssertTrue(downloadBanner.exists, "The sunset banners should be shown when registering against a homeserver with MAS.") + + let confirmButton = app.buttons["confirmButton"] + XCTAssertTrue(confirmButton.exists, "The confirm button should always be shown.") + XCTAssertFalse(confirmButton.isEnabled, "The confirm button should be disabled when there is an error.") + } } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift index 5fd2ed7cc7..d3036bf981 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/Test/Unit/AuthenticationServerSelectionViewModelTests.swift @@ -24,7 +24,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase { @MainActor func testErrorMessage() async throws { // Given a new instance of the view model. - XCTAssertNil(context.viewState.footerErrorMessage, "There should not be an error message for a new view model.") + XCTAssertNil(context.viewState.footerError, "There should not be an error message for a new view model.") XCTAssertFalse(context.viewState.isShowingFooterError, "There should not be an error shown.") // When an error occurs. @@ -32,7 +32,7 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase { viewModel.displayError(.footerMessage(message)) // Then the footer should now be showing an error. - XCTAssertEqual(context.viewState.footerErrorMessage, message, "The error message should be stored.") + XCTAssertEqual(context.viewState.footerError, .message(message), "The error message should be stored.") XCTAssertTrue(context.viewState.isShowingFooterError, "There should be an error shown.") // And when clearing the error. @@ -42,7 +42,31 @@ class AuthenticationServerSelectionViewModelTests: XCTestCase { await Task.yield() // Then the error message should now be removed. - XCTAssertNil(context.viewState.footerErrorMessage, "The error message should have been cleared.") + XCTAssertNil(context.viewState.footerError, "The error message should have been cleared.") + XCTAssertFalse(context.viewState.isShowingFooterError, "There should not be an error shown anymore.") + } + + @MainActor func testSunsetBanner() async throws { + // Given a new instance of the view model. + XCTAssertNil(context.viewState.footerError, "There should not be an error for a new view model.") + XCTAssertFalse(context.viewState.isShowingFooterError, "There should not be an error shown.") + + // When an error occurs. + let message = "Unable to contact server." + viewModel.displayError(.requiresReplacementApp) + + // Then the footer should now be showing an error. + XCTAssertEqual(context.viewState.footerError, .sunsetBanner, "The banner should be shown.") + XCTAssertTrue(context.viewState.isShowingFooterError, "There should be an error shown.") + + // And when clearing the error. + context.send(viewAction: .clearFooterError) + + // Wait for the action to spawn a Task on the main actor as the Context protocol doesn't support actors. + await Task.yield() + + // Then the error message should now be removed. + XCTAssertNil(context.viewState.footerError, "The error should have been cleared.") XCTAssertFalse(context.viewState.isShowingFooterError, "There should not be an error shown anymore.") } } diff --git a/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift index dd01b6e7ff..2b8c1e1879 100644 --- a/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift +++ b/RiotSwiftUI/Modules/Authentication/ServerSelection/View/AuthenticationServerSelectionScreen.swift @@ -70,14 +70,10 @@ struct AuthenticationServerSelectionScreen: View { var serverForm: some View { VStack(alignment: .leading, spacing: 12) { VStack(spacing: 8) { - if #available(iOS 15.0, *) { - textField - .onSubmit(submit) - } else { - textField - } + textField + .onSubmit(submit) - if let errorMessage = viewModel.viewState.footerErrorMessage { + if case let .message(errorMessage) = viewModel.viewState.footerError { Text(errorMessage) .font(theme.fonts.footnote) .foregroundColor(textFieldFooterColor) @@ -86,6 +82,8 @@ struct AuthenticationServerSelectionScreen: View { } } + sunsetBanners + Button(action: submit) { Text(viewModel.viewState.buttonTitle) } @@ -95,7 +93,6 @@ struct AuthenticationServerSelectionScreen: View { } } - /// The text field, extracted for iOS 15 modifiers to be applied. var textField: some View { TextField(VectorL10n.authenticationServerSelectionServerUrl, text: $viewModel.homeserverAddress) { isEditingTextField = $0 @@ -109,6 +106,23 @@ struct AuthenticationServerSelectionScreen: View { .accessibilityIdentifier("addressTextField") } + @ViewBuilder + var sunsetBanners: some View { + if viewModel.viewState.footerError == .sunsetBanner, let replacementApp = BuildSettings.replacementApp { + VStack(spacing: 16) { + SunsetOIDCRegistrationBanner(homeserverAddress: viewModel.homeserverAddress, + replacementApp: replacementApp) + + SunsetDownloadBanner(replacementApp: replacementApp) { + viewModel.send(viewAction: .downloadReplacementApp(replacementApp)) + } + } + .padding(.vertical, 4) + .padding(.bottom, 16) + .accessibilityIdentifier("sunsetBanners") + } + } + @ToolbarContentBuilder var toolbar: some ToolbarContent { ToolbarItem(placement: .cancellationAction) { diff --git a/RiotSwiftUI/Modules/Common/Sunset/SunsetDownloadBanner.swift b/RiotSwiftUI/Modules/Common/Sunset/SunsetDownloadBanner.swift new file mode 100644 index 0000000000..f50b1fb391 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Sunset/SunsetDownloadBanner.swift @@ -0,0 +1,66 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +struct SunsetDownloadBanner: View { + @Environment(\.theme) private var theme + + let replacementApp: BuildSettings.ReplacementApp + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .top, spacing: 13) { + Image(Asset.Images.sunsetBannerIcon.name) + .clipShape(RoundedRectangle(cornerRadius: 15.2)) + + VStack(alignment: .leading, spacing: 4) { + Text(VectorL10n.sunsetDownloadBannerTitle(replacementApp.name)) + .font(theme.fonts.title3SB) + .foregroundStyle(theme.colors.primaryContent) + + Text(VectorL10n.sunsetDownloadBannerMessage) + .font(theme.fonts.callout) + .foregroundStyle(theme.colors.secondaryContent) + + // Using a button rather than an attributed string so that it animates on tap. + Button(VectorL10n.sunsetDownloadBannerLearnMore) { + UIApplication.shared.open(replacementApp.learnMoreURL) + } + .font(theme.fonts.bodySB) + .tint(theme.colors.links) + .padding(.top, 4) + } + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(BannerButtonStyle()) + } +} + +private struct BannerButtonStyle: ButtonStyle { + @Environment(\.theme) private var theme + + let bannerShape = RoundedRectangle(cornerRadius: 8) + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(12) + .shapedBorder(color: theme.colors.quarterlyContent, borderWidth: 1.5, shape: bannerShape) + .background(configuration.isPressed ? theme.colors.system : theme.colors.background, in: bannerShape) + .contentShape(bannerShape) + } +} + +struct SunsetDownloadBanner_Previews: PreviewProvider { + static var previews: some View { + SunsetDownloadBanner(replacementApp: BuildSettings.replacementApp!) { } + .padding(.horizontal) + } +} diff --git a/RiotSwiftUI/Modules/Common/Sunset/SunsetOIDCRegistrationBanner.swift b/RiotSwiftUI/Modules/Common/Sunset/SunsetOIDCRegistrationBanner.swift new file mode 100644 index 0000000000..aff81d3db5 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Sunset/SunsetOIDCRegistrationBanner.swift @@ -0,0 +1,52 @@ +// +// Copyright 2025 New Vector Ltd +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +struct SunsetOIDCRegistrationBanner: View { + @Environment(\.theme) private var theme + + let homeserverAddress: String + let replacementApp: BuildSettings.ReplacementApp + + private let bannerShape = RoundedRectangle(cornerRadius: 8) + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Label { + Text(VectorL10n.sunsetDelegatedOidcRegistrationNotSupportedTitle(homeserverAddress)) + } icon: { + Image(systemName: "exclamationmark.circle.fill") + } + .font(theme.fonts.callout.bold()) + .foregroundStyle(theme.colors.alert) + + Label { + Text(VectorL10n.sunsetDelegatedOidcRegistrationNotSupportedMessage(replacementApp.name, homeserverAddress)) + .font(theme.fonts.footnote) + } icon: { + // Invisible Icon to align the Text with the one above. + Image(systemName: "circle") + .font(theme.fonts.callout.bold()) + .hidden() + } + .foregroundStyle(theme.colors.primaryContent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(theme.colors.alert.opacity(0.05), in: bannerShape) + .shapedBorder(color: theme.colors.alert.opacity(0.5), borderWidth: 2, shape: bannerShape) + } +} + +struct SunsetRegistrationAlert_Previews: PreviewProvider { + static var previews: some View { + SunsetOIDCRegistrationBanner(homeserverAddress: "beta.matrix.org", + replacementApp: BuildSettings.replacementApp!) + .padding(.horizontal) + } +} diff --git a/changelog.d/7889.api b/changelog.d/7889.api new file mode 100644 index 0000000000..8caf0115fa --- /dev/null +++ b/changelog.d/7889.api @@ -0,0 +1 @@ +Adds a BuildSettings.replacementApp setting, used to control the sunset banners. \ No newline at end of file diff --git a/changelog.d/7889.change b/changelog.d/7889.change new file mode 100644 index 0000000000..d5ebf11b1e --- /dev/null +++ b/changelog.d/7889.change @@ -0,0 +1 @@ +Show sunset banners, guiding users to install Element X when registering a new account against a server with MAS. \ No newline at end of file