ios, android, desktop: buy a badge in the browser, with a deep link back on mobile

This commit is contained in:
spaced4ndy
2026-09-25 18:45:12 +04:00
parent 0dc85d3311
commit d167013e84
13 changed files with 412 additions and 82 deletions
+22 -1
View File
@@ -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
+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; 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))
}
}
+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 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" />
@@ -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,
@@ -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))
}
}
@@ -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) }
}
)
}
@@ -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>