From 36da024394cc9c1a643e1995018c300a19c4d889 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:30:57 +0000 Subject: [PATCH] ui: buy a badge in the browser, with a deep link back on mobile (#7592) --- apps/ios/Shared/ContentView.swift | 25 +- apps/ios/Shared/SimpleXApp.swift | 3 +- apps/ios/Shared/Views/Badges/BadgeStore.swift | 3 + .../Views/Badges/BadgesRedeemCodeView.swift | 271 ++++++++++++++++-- .../Badges/BadgesSupportSimplexView.swift | 18 +- apps/ios/Shared/Views/Helpers/AppSheet.swift | 8 +- apps/ios/SimpleX--iOS--Info.plist | 10 + apps/ios/spec/client/navigation.md | 2 + .../android/src/main/AndroidManifest.xml | 9 + .../simplex/common/views/badges/BadgeStore.kt | 5 + .../views/badges/BadgesRedeemCodeView.kt | 265 ++++++++++++++--- .../views/badges/BadgesSupportSimplexView.kt | 28 +- .../common/views/chatlist/ChatListView.kt | 25 ++ .../simplex/common/views/helpers/ModalView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 13 +- apps/multiplatform/spec/client/navigation.md | 6 + .../web/public/index.html | 10 +- .../web/public/styles.css | 4 + apps/simplex-badge-service/web/public/sw.js | 2 +- apps/simplex-badge-service/web/src/screens.ts | 5 +- .../web/test/screens.test.ts | 4 +- 21 files changed, 623 insertions(+), 97 deletions(-) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 22d0da3829..32dffbf2e2 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -461,9 +461,14 @@ struct ContentView: View { } func connectViaUrl_(_ url: URL) { + // an app link must not tear down a redemption in flight + if isAppLink(url) && isBadgeLinkIssuing() { return } dismissAllSheets() { var path = url.path - if path == "/r" { + if isAppLink(url) { + // branched on the scheme before the connection dispatch, which an app link must never reach + openAppLink(url) + } else if path == "/r" { showAlert( NSLocalizedString("Relay address", comment: "alert title"), message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") @@ -498,6 +503,24 @@ struct ContentView: View { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +private let appLinkScheme = "simplexchat" +private let badgeLinkPath = "/badge/code/" + +func isAppLink(_ url: URL) -> Bool { + url.scheme?.lowercased() == appLinkScheme +} + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private func openAppLink(_ url: URL) { + let path = url.path + if path.hasPrefix(badgeLinkPath) { + openBadgeLink(String(path.dropFirst(badgeLinkPath.count))) + } else { + AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates."))) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e9a97c31b..28aa7e4002 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -43,7 +43,8 @@ struct SimpleXApp: App { .environmentObject(chatModel) .environmentObject(AppTheme.shared) .onOpenURL { url in - logger.debug("ContentView.onOpenURL: \(url)") + // an app link can carry a secret, such as a badge code that anyone who reads it can redeem + logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)") if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 8d092bbc19..c31e45439a 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -32,6 +32,9 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in // which is how the service learns which invoice a store transaction settles. func newBadgeInvoiceId() -> UUID { UUID() } +// the page's app flag rides in the fragment, which never reaches the service +let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" + enum BadgePrice { case loading case price(String) diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 6ea0c58edb..ed53b21173 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -33,10 +33,43 @@ private func formatBadgeCodeInput(_ s: String) -> String { return groups.joined(separator: "-") } +enum BadgeRedeemOutcome { + case redeemed + case refused(message: String) + case cancelled +} + +// sets the badge before returning, so a caller that dismisses on .redeemed lands on Your Badge +// instead of showing the switch from Support. .cancelled when the user cancels the retry alert. +func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome { + do { + guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled } + return await MainActor.run { () -> BadgeRedeemOutcome in + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + ChatModel.shared.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + return .refused(message: redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + ) + } + UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) + return .redeemed + } + } catch let error { + logger.error("apiRedeemBadgeCode: \(responseError(error))") + return .refused(message: redeemErrorText(error)) + } +} + +func showCannotRedeemAlert(_ message: String) { + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) +} + struct BadgesRedeemCodeView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @Environment(\.dismiss) var dismiss: DismissAction @State private var code = "" @State private var canonicalCode: String? = nil @@ -184,39 +217,221 @@ struct BadgesRedeemCodeView: View { guard let sending = canonicalCode, let user = chatModel.currentUser else { return } submitting = true Task { - do { - guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { - await MainActor.run { submitting = false } - return - } - await MainActor.run { - submitting = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) - chatModel.updateUser(redeemed.user) - if let badgeState = redeemed.badgeState, !badgeState.shown { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - let message = redeemed.newBadge - ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") - : NSLocalizedString("This code has already been used.", comment: "alert message") - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) - } else { - supporterBannerShown = true - dismiss() - } - } - } catch let error { - logger.error("apiRedeemBadgeCode: \(responseError(error))") - await MainActor.run { - submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + let outcome = await redeemBadgeCode(user, sending) + await MainActor.run { + submitting = false + switch outcome { + case .redeemed: dismiss() + case let .refused(message): showCannotRedeemAlert(message) + case .cancelled: break } } } } } +private weak var badgeLinkSheet: UIViewController? +// the open screen's own state, not a copy of its step, so the two cannot disagree +private weak var badgeLinkState: BadgeLinkState? + +func isBadgeLinkIssuing() -> Bool { + badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing +} + +func openBadgeLink(_ codeText: String) { + guard let code = parseBadgeCode(codeText) else { + return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message")) + } + let state = BadgeLinkState() + badgeLinkSheet = showAppSheet { + NavigationView { + BadgesRedeemLinkView(code: code, state: state) + .modifier(ThemedBackground()) + } + } + badgeLinkState = state +} + +enum BadgeLinkStep { + case confirming + case issuing + case redeemed + case viewingBadge +} + +final class BadgeLinkState: ObservableObject { + @Published var step = BadgeLinkStep.confirming +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +struct BadgesRedeemLinkView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + let code: String + @ObservedObject var state: BadgeLinkState + + var body: some View { + switch state.step { + case .confirming: if profileHasBadge { badgeHeld() } else { confirming() } + case .issuing: beingIssued() + case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true) + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + private var profileHasBadge: Bool { + badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true + } + + // the screen cannot tell a repeated link for the badge it shows from a code for another badge, + // so it asserts neither + private func badgeHeld() -> some View { + linkStep( + "Profile already has a badge", + primary: ("View your badge", { state.step = .viewingBadge }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge")) + linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge")) + } + } + + private func confirming() -> some View { + linkStep( + "Add badge to your profile?", + primary: ("Add badge", { redeemFromLink() }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? "")) + } + } + + // leaving does not cancel: the badge is still added after the screen closes + private func beingIssued() -> some View { + linkStep("Badge is being issued", textButton: ("Dismiss", { closeIfShowing() })) { + Spacer() + ProgressView().scaleEffect(2) + } + } + + // with no primary, the text button takes its place, so a button does not move between steps + private func linkStep( + _ title: LocalizedStringKey, + primary: (label: LocalizedStringKey, action: () -> Void)? = nil, + textButton: (label: LocalizedStringKey, action: () -> Void), + @ViewBuilder content: () -> Content + ) -> some View { + let textButtonLabel = Text(textButton.label) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + return VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + content() + + Spacer() + + VStack(spacing: 10) { + if let primary { + Button { + primary.action() + } label: { + Text(primary.label) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .padding(.vertical, 10) + + Button { + textButton.action() + } label: { + textButtonLabel + } + .frame(height: 22) + } else { + Button { + textButton.action() + } label: { + // the inset OnboardingButtonStyle gives a primary + textButtonLabel.padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func linkText(_ text: String) -> some View { + Text(text) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private func redeemFromLink() { + // a second tap before the screen changes must not send the code again + guard state.step == .confirming else { return } + guard let user = chatModel.currentUser else { return closeIfShowing() } + state.step = .issuing + Task { + let outcome = await redeemBadgeCode(user, code) + await MainActor.run { + switch outcome { + case .redeemed: + if !isShowing { + showAlert( + NSLocalizedString("Badge added", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), user.displayName) + ) + } + // a covered screen is not left on the spinner; a closed one is not written to + if isOpen { state.step = .redeemed } + case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) } + case .cancelled: closeIfShowing() + } + } + } + } + + private var isOpen: Bool { + badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil + } + + // an alert over this screen still counts: dismissing from the presenter dismisses it too + private var isShowing: Bool { + guard isOpen, let sheet = badgeLinkSheet else { return false } + return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController + } + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + private func closeIfShowing(then: @escaping () -> Void = {}) { + if isShowing, let presenter = badgeLinkSheet?.presentingViewController { + presenter.dismiss(animated: true, completion: then) + } else { + if isOpen { state.step = .confirming } + then() + } + } +} + struct BadgesRedeemCodeView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 44b1e48be1..22cec4a4f4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -47,9 +47,9 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) VStack(spacing: 10) { - redeemCodeButton() + buyInBrowserButton() .padding(.vertical, 10) - getCodeButton() + redeemCodeButton() .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) @@ -131,8 +131,10 @@ struct BadgesSupportSimplexView: View { redeemCodeActive = true } label: { Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $redeemCodeActive) { BadgesRedeemCodeView() @@ -145,15 +147,13 @@ struct BadgesSupportSimplexView: View { } } - private func getCodeButton() -> some View { + private func buyInBrowserButton() -> some View { Button { - openExternalLink(URL(string: "https://simplex.chat/badges/")!) + UIApplication.shared.open(URL(string: badgePageUrl)!) } label: { - Text("Get your code") - .font(.body) - .fontWeight(.medium) - .foregroundColor(theme.colors.primary) + Text("Buy in browser") } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) } } diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 51ae63f07e..d505cdead7 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier { // Presented from the top view controller instead of a .sheet on a parent view, so an alert button or // a view that is itself in a sheet can open it. -func showAppSheet(@ViewBuilder content: () -> Content) { +@discardableResult +func showAppSheet(@ViewBuilder content: () -> Content) -> UIViewController? { if let topController = getTopViewController() { let v = content() .modifier(PrivacySensitive()) .environmentObject(ChatModel.shared) .environmentObject(AppTheme.shared) - topController.present(UIHostingController(rootView: v), animated: true) + let sheet = UIHostingController(rootView: v) + topController.present(sheet, animated: true) + return sheet } + return nil } extension View { diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 72bd9b0dc3..06edbf9ce9 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -42,6 +42,16 @@ simplex + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app.deeplink + CFBundleURLSchemes + + simplexchat + + ITSAppUsesNonExemptEncryption diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 64d0940e39..329f764efa 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -200,6 +200,8 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added. While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL. + ### Call Deep Link Call invitations from notifications: diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 9e059afa14..c2c789f998 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -70,6 +70,15 @@ + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index f8a37c39a7..2c7dfd05f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -34,6 +34,11 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap // learns which invoice a store transaction settles. fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() +// the page's app flag rides in the fragment, which never reaches the service +val badgePageUrl: String = + if (appPlatform.isAndroid) "https://badges.simplex.chat/#/tier?app=true" + else "https://badges.simplex.chat/#/tier?app=desktop" + // what the platform store knows about one product; ProductDetails cannot cross into commonMain data class BadgeProduct( val id: BadgeStoreProductId, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 9ebaf9bcdc..7494229aaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -57,10 +58,44 @@ private fun formatBadgeCodeInput(s: String): String { return groups.joinToString("-") } +sealed class BadgeRedeemOutcome { + object Redeemed: BadgeRedeemOutcome() + class Refused(val message: String): BadgeRedeemOutcome() + object Cancelled: BadgeRedeemOutcome() +} + +// sets the badge before returning, so a caller that dismisses on Redeemed lands on Your Badge +// instead of showing the switch from Support. Cancelled when the user cancels the retry alert. +suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemOutcome = + when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, code)) { + null -> BadgeRedeemOutcome.Cancelled + is BadgeRedeemResult.Redeemed -> withContext(Dispatchers.Main) { + val badgeState = result.badgeState + BadgeModel.set(rhId, user.userId, badgeState) + chatModel.updateUser(result.user) + if (badgeState != null && !badgeState.shown) { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + BadgeRedeemOutcome.Refused( + generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) + ) + } else { + appPrefs.supporterBannerShown.set(true) + BadgeRedeemOutcome.Redeemed + } + } + is BadgeRedeemResult.Failed -> { + Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") + BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err)) + } + } + +fun showCannotRedeemAlert(message: String) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_error_title), text = message) +} + @Composable fun BadgesRedeemCodeView(modalManager: ModalManager) { val rhId = remember { chatModel.remoteHostId() } - val supporterBannerShown = remember { appPrefs.supporterBannerShown } val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } @@ -78,37 +113,13 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { val user = chatModel.currentUser.value ?: return submitting.value = true withBGApi { - when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { - null -> withContext(Dispatchers.Main) { submitting.value = false } - is BadgeRedeemResult.Redeemed -> { - val badgeState = result.badgeState - withContext(Dispatchers.Main) { - submitting.value = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.set(rhId, user.userId, badgeState) - chatModel.updateUser(result.user) - if (badgeState != null && !badgeState.shown) { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) - ) - } else { - supporterBannerShown.set(true) - modalManager.closeModal() - } - } - } - is BadgeRedeemResult.Failed -> { - Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") - withContext(Dispatchers.Main) { - submitting.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = chatModel.controller.redeemErrorText(result.err) - ) - } + val outcome = redeemBadgeCode(rhId, user, sending) + withContext(Dispatchers.Main) { + submitting.value = false + when (outcome) { + is BadgeRedeemOutcome.Redeemed -> modalManager.closeModal() + is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message) + is BadgeRedeemOutcome.Cancelled -> {} } } } @@ -244,3 +255,193 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { onclick = onClick ) } + +// the open screen's own state, not a copy of its step, so the two cannot disagree +private var badgeLinkStep: MutableState? = null + +private fun isBadgeLinkOpen(): Boolean = + ModalManager.end.hasModalOpen(ModalViewId.BADGE_LINK) + +fun isBadgeLinkIssuing(): Boolean = + isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing + +fun openBadgeLink(rhId: Long?, codeText: String) { + val code = parseBadgeCode(codeText) + ?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code)) + // opens over the chat list, as iOS dismisses every sheet before a link + ModalManager.closeAllModalsEverywhere() + // held by the modal, not remembered: a modal is composed only while on top, and rotation recreates the activity, + // either of which would reset remembered state to Confirming with a request in flight + val step = mutableStateOf(BadgeLinkStep.Confirming) + ModalManager.end.showCustomModal(id = ModalViewId.BADGE_LINK) { close -> + BadgesRedeemLinkView(rhId, code, step, close) + } + badgeLinkStep = step +} + +enum class BadgeLinkStep { + Confirming, + Issuing, + Redeemed, + ViewingBadge +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +@Composable +fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState, close: () -> Unit) { + fun isOpen(): Boolean = + badgeLinkStep === step && isBadgeLinkOpen() + + fun isShowing(): Boolean = + isOpen() && ModalManager.end.isLastModalOpen(ModalViewId.BADGE_LINK) + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + fun closeIfShowing() { + if (isShowing()) { + close() + } else if (isOpen()) { + step.value = BadgeLinkStep.Confirming + } + } + + fun redeemFromLink() { + // a second tap before the screen changes must not send the code again + if (step.value != BadgeLinkStep.Confirming) return + val user = chatModel.currentUser.value ?: return closeIfShowing() + step.value = BadgeLinkStep.Issuing + withBGApi { + val outcome = redeemBadgeCode(rhId, user, code) + withContext(Dispatchers.Main) { + when (outcome) { + is BadgeRedeemOutcome.Redeemed -> { + if (!isShowing()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_link_added_title), + text = String.format(generalGetString(MR.strings.badges_link_added_profile), user.displayName) + ) + } + // a covered screen is not left on the spinner; a closed one is not written to + if (isOpen()) step.value = BadgeLinkStep.Redeemed + } + is BadgeRedeemOutcome.Refused -> { + closeIfShowing() + showCannotRedeemAlert(outcome.message) + } + is BadgeRedeemOutcome.Cancelled -> closeIfShowing() + } + } + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + val profileHasBadge = BadgeModel.isCurrent(rhId, chatModel.currentUser.value?.userId) && BadgeModel.badgeState.value?.shown == true + + when (step.value) { + BadgeLinkStep.Confirming -> ModalView(::closeIfShowing) { + if (profileHasBadge) { + BadgeHeld(onViewBadge = { step.value = BadgeLinkStep.ViewingBadge }, onCancel = ::closeIfShowing) + } else { + Confirming(onConfirm = ::redeemFromLink, onCancel = ::closeIfShowing) + } + } + BadgeLinkStep.Issuing -> ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) } + BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.end, close) + } +} + +// the screen cannot tell a repeated link for the badge it shows from a code for another badge, +// so it asserts neither +@Composable +private fun BadgeHeld(onViewBadge: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_held_title, + primary = MR.strings.badges_link_view_badge to onViewBadge, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(stringResource(MR.strings.badges_link_held_other_profile)) + LinkText(stringResource(MR.strings.badges_link_held_page)) + } +} + +@Composable +private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_confirm_title, + primary = MR.strings.badges_link_add_badge to onConfirm, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: "")) + } +} + +// leaving does not cancel: the badge is still added after the screen closes +@Composable +private fun BeingIssued(onDismiss: () -> Unit) { + LinkStep(MR.strings.badges_being_issued, textButton = MR.strings.badges_dismiss to onDismiss) { + Spacer(Modifier.weight(1f)) + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +// with no primary, the text button takes its place, so a button does not move between steps +@Composable +private fun LinkStep( + title: StringResource, + primary: Pair Unit>? = null, + textButton: Pair Unit>, + content: @Composable ColumnScope.() -> Unit +) { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + content() + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (primary != null) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = primary.first, + onboarding = null, + onclick = primary.second + ) + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + } else { + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + TextButtonBelowOnboardingButton("", null) + } + } + } +} + +@Composable +private fun LinkText(text: String) { + Text( + text, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index 7fa32edaf3..dc65897547 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -66,8 +66,8 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { + BuyInBrowserButton(modalManager) RedeemCodeButton(modalManager) - GetCodeButton() } } } @@ -112,22 +112,26 @@ private fun HowItWorksButton(modalManager: ModalManager) { @Composable private fun RedeemCodeButton(modalManager: ModalManager) { - OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_redeem_code_button, - onboarding = null, - onclick = { - modalManager.showModal { BadgesRedeemCodeView(modalManager) } - } + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_redeem_code_button), + onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager) } } ) } @Composable -private fun GetCodeButton() { +private fun BuyInBrowserButton(modalManager: ModalManager) { val uriHandler = LocalUriHandler.current - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_get_your_code), - onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") } + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_buy_in_browser, + onboarding = null, + onclick = { + uriHandler.openUriCatching(badgePageUrl) + // desktop has no scheme to bring the code back, so the code is pasted into this screen, opened beside the browser + if (appPlatform.isDesktop && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager) } + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index fae1b7c155..5fe3b7d5f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -776,6 +776,10 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = rhId to uri + } else if (isAppLink(uri)) { + // branched on the scheme before the connection dispatch, which an app link must never reach + // an app link must not tear down a redemption in flight + if (!isBadgeLinkIssuing()) openAppLink(rhId, uri) } else { withBGApi { chatModel.appOpenUrlConnecting.value = true @@ -784,6 +788,27 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +private const val appLinkScheme = "simplexchat" +private const val badgeLinkPath = "/badge/code/" + +// the prefix of the raw text, not a parsed URI: a link that does not parse is still an app link +fun isAppLink(uri: String): Boolean = + uri.startsWith("$appLinkScheme:", ignoreCase = true) + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private fun openAppLink(rhId: Long?, uri: String) { + // a link that does not parse is dropped, as iOS never receives one; one that parses without + // a path, such as simplexchat:badge/code/X, is an unknown link on both platforms + val parsed = uriCreateOrNull(uri) ?: return + val path = parsed.path ?: "" + if (path.startsWith(badgeLinkPath)) { + openBadgeLink(rhId, path.removePrefix(badgeLinkPath)) + } else { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.app_link_not_supported)) + } +} + @Composable private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { Box { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 02c0b45de4..0511bb1cba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -91,7 +91,9 @@ class ModalData(val keyboardCoversBar: Boolean = true) { enum class ModalViewId { SECONDARY_CHAT, - CONTEXT_USER_PICKER_INCOGNITO + CONTEXT_USER_PICKER_INCOGNITO, + BADGE_LINK, + BADGE_REDEEM_CODE } class ModalManager(private val placement: ModalPlacement? = null) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 82dfe5152e..957160041b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -25,6 +25,7 @@ You are an owner Invalid link Please check that SimpleX link is correct. + This link is not supported by this app version. Please check for app updates. Opening database… @@ -3182,8 +3183,18 @@ Purchase pending The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error - Get your code + Buy in browser Redeem code + Add badge to your profile? + The badge will be added to the profile %1$s. + Add badge + Profile already has a badge + A badge can be added to another profile, or here once this badge ends. + Its code is on the page you bought it on, under Show code. + View your badge + Badge is being issued + Badge added + The badge was added to the profile %1$s. Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 34a3b502ee..427e31d9d8 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -107,6 +107,12 @@ When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). 2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +### URL Deep Link + +On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. Desktop registers no URL scheme. + +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L796) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L800) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L268), which shows `BadgesRedeemLinkView` as a `ModalManager.end` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added. Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L265)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string. + ### Overlay Layers (bottom of MainScreen) | Layer | Condition | Content | diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html index 6caac72b84..9885481483 100644 --- a/apps/simplex-badge-service/web/public/index.html +++ b/apps/simplex-badge-service/web/public/index.html @@ -20,17 +20,17 @@ Support SimpleX - - + + - + - + - + diff --git a/apps/simplex-badge-service/web/public/styles.css b/apps/simplex-badge-service/web/public/styles.css index 2fee434fcd..760cc259e7 100644 --- a/apps/simplex-badge-service/web/public/styles.css +++ b/apps/simplex-badge-service/web/public/styles.css @@ -477,6 +477,10 @@ footer a:hover { text-decoration: underline; } .panel > .primary { margin-top: auto; } .panel > .notes { margin-top: auto; } .notes + .primary { margin-top: 20px; } +/* The ending's button keeps the foot position every screen gives it, but the text above it + keeps a floor: once Show code fills the screen there is no free space for the auto margin, + and the button would otherwise land against the line it follows. */ +.panel.ending > .lede { margin-bottom: 24px; } .notes .muted { margin: 4px auto 0; max-width: 480px; } /* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a method moves nothing above or below it. */ diff --git a/apps/simplex-badge-service/web/public/sw.js b/apps/simplex-badge-service/web/public/sw.js index c0c46f2942..447530421a 100644 --- a/apps/simplex-badge-service/web/public/sw.js +++ b/apps/simplex-badge-service/web/public/sw.js @@ -2,7 +2,7 @@ // runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`. /** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */ -const BUILD = "55cd5080cd9b28a4"; +const BUILD = "c1e049e40dfe7be6"; const ASSETS = `/assets/${BUILD}/`; /** One cache per build, so eviction is "everything that is not this one". */ const CACHE = `sb-${BUILD}`; diff --git a/apps/simplex-badge-service/web/src/screens.ts b/apps/simplex-badge-service/web/src/screens.ts index 84e2135a00..8ebda80496 100644 --- a/apps/simplex-badge-service/web/src/screens.ts +++ b/apps/simplex-badge-service/web/src/screens.ts @@ -846,11 +846,12 @@ export function returnToApp(o: ReturnToAppOptions): HTMLElement { el("div", { class: "tick" }, "✓"), el("h1", { class: "tight" }, "Paid"), el("p", { class: "lede" }, - el("span", { class: "line" }, "Opening SimpleX to add your badge."), " ", - el("span", { class: "line" }, "If nothing happens, use the button."), + el("span", { class: "line" }, "Your badge is ready."), " ", + el("span", { class: "line" }, "Return to SimpleX to add it."), ), button(RETURN_TO_APP, o.onReturn), ); + p.classList.add("ending"); switch (o.code.kind) { case "hidden": p.append(el("p", { class: "row-line center" }, button(SHOW_CODE, o.code.onShow, "link"))); diff --git a/apps/simplex-badge-service/web/test/screens.test.ts b/apps/simplex-badge-service/web/test/screens.test.ts index 00bac7561c..50370be92b 100644 --- a/apps/simplex-badge-service/web/test/screens.test.ts +++ b/apps/simplex-badge-service/web/test/screens.test.ts @@ -706,8 +706,8 @@ domTest("screens: the return-to-app ending opens on the tick, offers the link an assert.equal(p.children[0], p.all("div.tick")[0], "the payment went through, and this is the first thing that says so"); assert.equal(p.all("div.tick")[0]!.textContent, "✓"); assert.equal(p.all("h1")[0]!.textContent, "Paid"); - assert.ok(p.textContent.includes("Opening SimpleX to add your badge.")); - assert.ok(p.textContent.includes("If nothing happens, use the button.")); + assert.ok(p.textContent.includes("Your badge is ready.")); + assert.ok(p.textContent.includes("Return to SimpleX to add it.")); assertNoCode(p, "the return-to-app ending"); p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); p.all("button.link").find((b) => b.textContent === screens.SHOW_CODE)!.click();