mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 17:49:07 +00:00
ios, android, desktop: buy a badge in the browser, with a deep link back on mobile
This commit is contained in:
@@ -463,7 +463,10 @@ struct ContentView: View {
|
||||
func connectViaUrl_(_ url: URL) {
|
||||
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 +501,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
|
||||
|
||||
@@ -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; change it to point Buy in browser at another deployment
|
||||
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,33 +217,128 @@ 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
|
||||
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
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openBadgeLink(_ codeText: String) {
|
||||
guard let code = parseBadgeCode(codeText) else {
|
||||
return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message"))
|
||||
}
|
||||
showAppSheet {
|
||||
NavigationView {
|
||||
BadgesRedeemLinkView(code: code)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BadgeLinkStep {
|
||||
case confirming
|
||||
case issuing
|
||||
case redeemed
|
||||
}
|
||||
|
||||
// Any web page or chat message 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.
|
||||
struct BadgesRedeemLinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
let code: String
|
||||
@State private var step = BadgeLinkStep.confirming
|
||||
|
||||
var body: some View {
|
||||
switch step {
|
||||
case .confirming: confirming()
|
||||
case .issuing: beingIssued()
|
||||
case .redeemed: BadgesView(showsAsSheet: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func confirming() -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text("Add badge to your profile?")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? ""))
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
redeemFromLink()
|
||||
} label: {
|
||||
Text("Add badge")
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiRedeemBadgeCode: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
submitting = false
|
||||
showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error))
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Button {
|
||||
dismissAllSheets()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
}
|
||||
.frame(height: 22)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 48)
|
||||
.padding(.bottom, 20)
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func beingIssued() -> some View {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
Text("Badge is being issued")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressView().scaleEffect(2)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 48)
|
||||
.padding(.bottom, 20)
|
||||
.frame(maxHeight: .infinity)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func redeemFromLink() {
|
||||
guard let user = chatModel.currentUser else { return dismissAllSheets() }
|
||||
step = .issuing
|
||||
Task {
|
||||
let outcome = await redeemBadgeCode(user, code)
|
||||
await MainActor.run {
|
||||
switch outcome {
|
||||
case .redeemed: step = .redeemed
|
||||
case let .refused(message): dismissAllSheets { showCannotRedeemAlert(message) }
|
||||
case .cancelled: dismissAllSheets()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 page or message 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. 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" />
|
||||
|
||||
|
||||
+5
@@ -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; change it to point Buy in browser at another deployment
|
||||
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,
|
||||
|
||||
+154
-32
@@ -57,10 +57,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 +112,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 +254,115 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
onclick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
fun openBadgeLink(rhId: Long?, codeText: String) {
|
||||
val code = parseBadgeCode(codeText)
|
||||
?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code))
|
||||
ModalManager.end.showCustomModal { close ->
|
||||
BadgesRedeemLinkView(rhId, code, close)
|
||||
}
|
||||
}
|
||||
|
||||
enum class BadgeLinkStep {
|
||||
Confirming,
|
||||
Issuing,
|
||||
Redeemed
|
||||
}
|
||||
|
||||
// Any web page or chat message 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.
|
||||
@Composable
|
||||
fun BadgesRedeemLinkView(rhId: Long?, code: String, close: () -> Unit) {
|
||||
val step = remember { mutableStateOf(BadgeLinkStep.Confirming) }
|
||||
|
||||
fun redeemFromLink() {
|
||||
val user = chatModel.currentUser.value ?: return close()
|
||||
step.value = BadgeLinkStep.Issuing
|
||||
withBGApi {
|
||||
val outcome = redeemBadgeCode(rhId, user, code)
|
||||
withContext(Dispatchers.Main) {
|
||||
when (outcome) {
|
||||
is BadgeRedeemOutcome.Redeemed -> step.value = BadgeLinkStep.Redeemed
|
||||
is BadgeRedeemOutcome.Refused -> {
|
||||
close()
|
||||
showCannotRedeemAlert(outcome.message)
|
||||
}
|
||||
is BadgeRedeemOutcome.Cancelled -> close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (step.value) {
|
||||
BadgeLinkStep.Confirming -> ModalView(close) { Confirming(onConfirm = ::redeemFromLink, onCancel = close) }
|
||||
BadgeLinkStep.Issuing -> ModalView(close) { BeingIssued() }
|
||||
BadgeLinkStep.Redeemed -> BadgesView(ModalManager.end, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Confirming(onConfirm: () -> Unit, onCancel: () -> 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(MR.strings.badges_link_confirm_title),
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: ""),
|
||||
style = MaterialTheme.typography.body1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OnboardingActionButton(
|
||||
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
|
||||
labelId = MR.strings.badges_link_add_badge,
|
||||
onboarding = null,
|
||||
onclick = onConfirm
|
||||
)
|
||||
TextButtonBelowOnboardingButton(stringResource(MR.strings.cancel_verb), onCancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BeingIssued() {
|
||||
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(MR.strings.badges_being_issued),
|
||||
style = MaterialTheme.typography.h1,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(30.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 3.dp
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
+14
-12
@@ -66,8 +66,8 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BuyInBrowserButton(modalManager)
|
||||
RedeemCodeButton(modalManager)
|
||||
GetCodeButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,22 +112,24 @@ 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.showModal { BadgesRedeemCodeView(modalManager) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -776,6 +776,9 @@ 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
|
||||
openAppLink(rhId, uri)
|
||||
} else {
|
||||
withBGApi {
|
||||
chatModel.appOpenUrlConnecting.value = true
|
||||
@@ -784,6 +787,25 @@ 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
|
||||
val path = uriCreateOrNull(uri)?.path ?: return
|
||||
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 {
|
||||
|
||||
@@ -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,12 @@
|
||||
<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_being_issued">Badge is being issued</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>
|
||||
|
||||
Reference in New Issue
Block a user