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 0000000000..3a12a28eb7 Binary files /dev/null and b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon.png differ diff --git a/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@2x.png b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@2x.png new file mode 100644 index 0000000000..563458c53b Binary files /dev/null and b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@2x.png differ diff --git a/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@3x.png b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@3x.png new file mode 100644 index 0000000000..6a65fa99a5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/sunset_banner_icon.imageset/ElementXBannerIcon@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index d0030805df..6adee1f2a7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -98,6 +98,14 @@ "accessibility_button_label" = "button"; "accessibility_selected" = "selected"; +// MARK: Sunset +"sunset_delegated_oidc_registration_not_supported_title" = "You can no longer create an account with %1$@ using this app"; +"sunset_delegated_oidc_registration_not_supported_message" = "Download %1$@ to use %2$@ for your account or choose a different homeserver."; +"sunset_delegated_oidc_registration_not_supported_generic_error" = "You can no longer create an account with the entered homeserver using this app"; +"sunset_download_banner_title" = "Download %1$@"; +"sunset_download_banner_message" = "Faster, more secure, and packed with powerful collaboration tools."; +"sunset_download_banner_learn_more" = "Learn more"; + // MARK: Onboarding "onboarding_splash_register_button_title" = "Create account"; "onboarding_splash_login_button_title" = "I already have an account"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index ce49740834..772a9957b4 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -355,6 +355,7 @@ internal class Asset: NSObject { internal static let voiceBroadcastTileMic = ImageAsset(name: "voice_broadcast_tile_mic") internal static let voiceBroadcastTimeLeft = ImageAsset(name: "voice_broadcast_time_left") internal static let launchScreenLogo = ImageAsset(name: "launch_screen_logo") + internal static let sunsetBannerIcon = ImageAsset(name: "sunset_banner_icon") } @objcMembers @objc(AssetSharedImages) internal class SharedImages: NSObject { diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 5b168bcee2..d4dc0d142e 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8743,6 +8743,30 @@ public class VectorL10n: NSObject { public static var suggest: String { return VectorL10n.tr("Vector", "suggest") } + /// You can no longer create an account with the entered homeserver using this app + public static var sunsetDelegatedOidcRegistrationNotSupportedGenericError: String { + return VectorL10n.tr("Vector", "sunset_delegated_oidc_registration_not_supported_generic_error") + } + /// Download %1$@ to use %2$@ for your account or choose a different homeserver. + public static func sunsetDelegatedOidcRegistrationNotSupportedMessage(_ p1: String, _ p2: String) -> 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