ui: buy a badge in the browser, with a deep link back on mobile (#7592)

This commit is contained in:
spaced4ndy
2026-09-29 10:30:57 +00:00
committed by GitHub
parent 0dc85d3311
commit 36da024394
21 changed files with 623 additions and 97 deletions
+24 -1
View File
@@ -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
+2 -1
View File
@@ -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 {
@@ -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)
@@ -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<Content: View>(
_ 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 {
@@ -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))
}
}
+6 -2
View File
@@ -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<Content: View>(@ViewBuilder content: () -> Content) {
@discardableResult
func showAppSheet<Content: View>(@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 {
+10
View File
@@ -42,6 +42,16 @@
<string>simplex</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>chat.simplex.app.deeplink</string>
<key>CFBundleURLSchemes</key>
<array>
<string>simplexchat</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
+2
View File
@@ -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/<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:
@@ -70,6 +70,15 @@
<data android:scheme="simplex" />
</intent-filter>
<!-- open simplexchat:/ app URI -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplexchat" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
@@ -34,6 +34,11 @@ val badgeStoreProductIds: List<BadgeStoreProductId> = 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,
@@ -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<String?>(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<BadgeLinkStep>? = 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<BadgeLinkStep>, 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<StringResource, () -> Unit>? = null,
textButton: Pair<StringResource, () -> 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()
)
}
@@ -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) }
}
}
)
}
@@ -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<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
Box {
@@ -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) {
@@ -25,6 +25,7 @@
<string name="connect_plan_you_are_owner">You are an owner</string>
<string name="error_parsing_uri_title">Invalid link</string>
<string name="error_parsing_uri_desc">Please check that SimpleX link is correct.</string>
<string name="app_link_not_supported">This link is not supported by this app version. Please check for app updates.</string>
<!-- MainActivity.kt -->
<string name="opening_database">Opening database…</string>
@@ -3182,8 +3183,18 @@
<string name="badges_purchase_pending">Purchase pending</string>
<string name="badges_purchase_pending_desc">The purchase is awaiting approval. This build does not deliver purchases approved later.</string>
<string name="badges_purchase_error">Purchase error</string>
<string name="badges_get_your_code">Get your code</string>
<string name="badges_buy_in_browser">Buy in browser</string>
<string name="badges_redeem_code_title">Redeem code</string>
<string name="badges_link_confirm_title">Add badge to your profile?</string>
<string name="badges_link_confirm_profile">The badge will be added to the profile %1$s.</string>
<string name="badges_link_add_badge">Add badge</string>
<string name="badges_link_held_title">Profile already has a badge</string>
<string name="badges_link_held_other_profile">A badge can be added to another profile, or here once this badge ends.</string>
<string name="badges_link_held_page">Its code is on the page you bought it on, under Show code.</string>
<string name="badges_link_view_badge">View your badge</string>
<string name="badges_being_issued">Badge is being issued</string>
<string name="badges_link_added_title">Badge added</string>
<string name="badges_link_added_profile">The badge was added to the profile %1$s.</string>
<string name="badges_redeem_code_body">Paste the code you received.</string>
<string name="badges_redeem_code_placeholder" translatable="false">SB-XXXXX-XXXXX-XXXXX-XXXXX</string>
<string name="badges_redeem">Redeem</string>
@@ -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/<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 |
@@ -20,17 +20,17 @@
<title>Support SimpleX</title>
<!-- The official symbol, both variants, served from the build hash like every
other asset so the worker precaches it. -->
<link rel="icon" type="image/svg+xml" href="/assets/55cd5080cd9b28a4/symbol-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="/assets/55cd5080cd9b28a4/symbol-dark.svg" media="(prefers-color-scheme: dark)">
<link rel="icon" type="image/svg+xml" href="/assets/c1e049e40dfe7be6/symbol-light.svg" media="(prefers-color-scheme: light)">
<link rel="icon" type="image/svg+xml" href="/assets/c1e049e40dfe7be6/symbol-dark.svg" media="(prefers-color-scheme: dark)">
<!-- The stylesheet before the script, not after: a classic script waits for a preceding stylesheet
to load before it runs, which holds the parser here until the CSS is in. Firefox otherwise paints
the parsed body unstyled while the stylesheet is still in flight (Chrome does not); blocking the
parser at this point keeps that unstyled frame off the screen. -->
<link rel="stylesheet" href="/assets/55cd5080cd9b28a4/styles.css">
<link rel="stylesheet" href="/assets/c1e049e40dfe7be6/styles.css">
<!-- The pre-paint bootstrap: sets the saved theme and marks a non-landing reload before the first
paint. Classic and blocking, served under the build hash (see `assets` in build.js) so it is
precached and cannot skew. Its wait on the stylesheet above still finishes before the first paint. -->
<script src="/assets/55cd5080cd9b28a4/init.js"></script>
<script src="/assets/c1e049e40dfe7be6/init.js"></script>
</head>
<body>
<!-- The app shell. `build.js` fills these two slots at build time with the serialized output of
@@ -44,6 +44,6 @@
<!-- Named, so `main.ts` can take it out of the accessibility tree with the
screen while the menu is open. -->
<footer id="contact"><a href="https://simplex.chat/contact">simplex.chat/contact</a></footer>
<script type="module" src="/assets/55cd5080cd9b28a4/main.js"></script>
<script type="module" src="/assets/c1e049e40dfe7be6/main.js"></script>
</body>
</html>
@@ -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. */
+1 -1
View File
@@ -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}`;
@@ -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")));
@@ -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();