mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 19:42:13 +00:00
SimpleX names v2: Android, desktop and iOS UIs
The names hub and the buy / gift / receive / renew / recovery-key flows across the Kotlin multiplatform app and the iOS Swift app. Stacked on the core PR. iOS is written but unbuilt: it needs a Mac build (no iOS job in CI) and a core lib rebuilt from the new simplexmq pin; expect MsgContent.assetTransfer exhaustiveness one-liners and a few accessor fixes on first xcodebuild.
This commit is contained in:
1 parent
dd0dcbf2bc
commit
31cd2619da
34 files changed
+2970
-7
No files matched your search
+30
@@ -0,0 +1,30 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.provider.CalendarContract
|
||||
|
||||
actual fun addCalendarReminder(title: String, description: String, epochSeconds: Long, remindDaysBefore: Int): Boolean {
|
||||
val startMs = epochSeconds * 1000
|
||||
val intent = Intent(Intent.ACTION_INSERT).apply {
|
||||
data = CalendarContract.Events.CONTENT_URI
|
||||
putExtra(CalendarContract.Events.TITLE, title)
|
||||
putExtra(CalendarContract.Events.DESCRIPTION, description)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, startMs)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, startMs)
|
||||
putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true)
|
||||
// Alert ahead of the date, not on it: by the expiry itself there is no
|
||||
// time left to act. Minutes is the unit CalendarContract takes.
|
||||
putExtra(CalendarContract.Reminders.MINUTES, remindDaysBefore * 24 * 60)
|
||||
putExtra(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
return try {
|
||||
androidAppContext.startActivity(intent)
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// No calendar app installed: say so rather than failing silently.
|
||||
Log.e(TAG, "addCalendarReminder: no calendar app")
|
||||
false
|
||||
}
|
||||
}
|
||||
+57
-4
@@ -153,6 +153,8 @@ object ChatModel {
|
||||
var terminalsVisible = setOf<Boolean>()
|
||||
val terminalItems = mutableStateOf<List<TerminalItem>>(listOf())
|
||||
val userAddress = mutableStateOf<UserContactLinkRec?>(null)
|
||||
// Names sent to this profile and not yet accepted or declined.
|
||||
val namesWaiting = mutableStateOf(0)
|
||||
val chatItemTTL = mutableStateOf<ChatItemTTL>(ChatItemTTL.None)
|
||||
|
||||
// set when app opened from external intent
|
||||
@@ -2053,7 +2055,11 @@ data class Profile(
|
||||
// the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge),
|
||||
// but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof
|
||||
val badge: BadgeProof? = null,
|
||||
val contactDomain: SimplexDomainClaim? = null
|
||||
val contactDomain: SimplexDomainClaim? = null,
|
||||
// published stealth meta-address (hex): lets a contact send this profile a
|
||||
// name with no handshake. Not an address and never on chain - holding it
|
||||
// confers only the ability to send. Absent on incognito profiles.
|
||||
val metaAddress: String? = null
|
||||
): NamedChat {
|
||||
override val profileDescription: String? get() = description
|
||||
|
||||
@@ -2062,7 +2068,7 @@ data class Profile(
|
||||
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
|
||||
}
|
||||
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
|
||||
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain, metaAddress = metaAddress)
|
||||
|
||||
companion object {
|
||||
val sampleData = Profile(
|
||||
@@ -2087,13 +2093,14 @@ data class LocalProfile(
|
||||
val peerType: ChatPeerType? = null,
|
||||
val localBadge: LocalBadge? = null,
|
||||
val contactDomain: SimplexDomainClaim? = null,
|
||||
val contactDomainVerified: Boolean? = null
|
||||
val contactDomainVerified: Boolean? = null,
|
||||
val metaAddress: String? = null
|
||||
): NamedChat {
|
||||
override val profileDescription: String? get() = description
|
||||
|
||||
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
|
||||
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
|
||||
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain, metaAddress = metaAddress)
|
||||
|
||||
companion object {
|
||||
val sampleData = LocalProfile(
|
||||
@@ -2365,6 +2372,38 @@ object GroupTypeSerializer : KSerializer<GroupType> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What was handed over. [kind] selects the wording and the screen to open, so a
|
||||
* token transfer later is a new kind rather than a new message type.
|
||||
*
|
||||
* [ephemeralPubKey] is what makes it usable at once: the recipient derives the
|
||||
* destination on receipt instead of scanning the chain for it.
|
||||
*/
|
||||
@Serializable
|
||||
data class AssetTransfer(
|
||||
val kind: String,
|
||||
val asset: String,
|
||||
val ephemeralPubKey: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val KIND_SIMPLEX_NAME = "simplexName"
|
||||
}
|
||||
}
|
||||
|
||||
/** A name this profile holds, with enough to show its state in a list. */
|
||||
@Serializable
|
||||
data class OwnedName(
|
||||
val onFqdn: String,
|
||||
val onExpires: Int,
|
||||
)
|
||||
|
||||
/** A name sitting at a one-time address, waiting to be accepted or declined. */
|
||||
@Serializable
|
||||
data class IncomingName(
|
||||
val inAddress: String,
|
||||
val inNames: List<String>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SimplexDomainClaim(
|
||||
val domain: String,
|
||||
@@ -4549,6 +4588,10 @@ sealed class MsgContent {
|
||||
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCChat(override val text: String, val chatLink: MsgChatLink, val ownerSig: LinkOwnerSig? = null): MsgContent()
|
||||
// Something handed over in-app: a name now, a token later. The reader renders
|
||||
// it from `transfer`, so wording is localised by the recipient rather than
|
||||
// sent in the sender's language; `text` is the fallback for older clients.
|
||||
@Serializable(with = MsgContentSerializer::class) class MCAssetTransfer(override val text: String, val transfer: AssetTransfer): MsgContent()
|
||||
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
|
||||
|
||||
val isVoice: Boolean get() =
|
||||
@@ -4667,6 +4710,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
val reason = decoder.json.decodeFromString<ReportReason>(json["reason"].toString())
|
||||
MsgContent.MCReport(text, reason)
|
||||
}
|
||||
"assetTransfer" -> {
|
||||
val t2 = decoder.json.decodeFromString<AssetTransfer>(json["transfer"].toString())
|
||||
MsgContent.MCAssetTransfer(text, t2)
|
||||
}
|
||||
"chat" -> {
|
||||
val chatLink = decoder.json.decodeFromString<MsgChatLink>(json["chatLink"].toString())
|
||||
val ownerSig = json["ownerSig"]?.let { decoder.json.decodeFromJsonElement<LinkOwnerSig>(it) }
|
||||
@@ -4720,6 +4767,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
|
||||
put("type", "file")
|
||||
put("text", value.text)
|
||||
}
|
||||
is MsgContent.MCAssetTransfer ->
|
||||
buildJsonObject {
|
||||
put("type", "assetTransfer")
|
||||
put("text", value.text)
|
||||
put("transfer", json.encodeToJsonElement(value.transfer))
|
||||
}
|
||||
is MsgContent.MCReport ->
|
||||
buildJsonObject {
|
||||
put("type", "report")
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package chat.simplex.common.model
|
||||
|
||||
/**
|
||||
* Paying for a name.
|
||||
*
|
||||
* This is the seam where the app store goes. The real implementation asks
|
||||
* StoreKit (iOS) or Play Billing (Android) to charge for a product and hands
|
||||
* back the receipt; the core forwards that receipt to the names service, which
|
||||
* validates it against Apple's or Google's server API before registering
|
||||
* anything. Nothing outside this file interprets a payment.
|
||||
*
|
||||
* Until billing is integrated, [purchase] returns a development token that the
|
||||
* mock service accepts. Replacing this one function with a real billing call is
|
||||
* the whole of the client-side work — the command, the request shape and the
|
||||
* service interface already carry a payment proof.
|
||||
*/
|
||||
object NamePayment {
|
||||
/** Product identifiers, mirroring what would be configured in the stores. */
|
||||
fun productId(years: Int): String = "chat.simplex.name.${years}y"
|
||||
|
||||
data class Result(val token: String)
|
||||
|
||||
data class Failure(val reason: String)
|
||||
|
||||
/**
|
||||
* Charge for [years] of a name and return a proof of payment.
|
||||
*
|
||||
* Returns null if the user cancelled or the charge failed. A real
|
||||
* implementation must be idempotent: a receipt is consumed once by the
|
||||
* service, and a retry after a network failure must not charge twice.
|
||||
*/
|
||||
suspend fun purchase(years: Int): Result? {
|
||||
// DEVELOPMENT ONLY: no charge is made and nothing is verified.
|
||||
// The mock names service accepts any token; the real one will not.
|
||||
return Result("dev-mock-payment-${productId(years)}")
|
||||
}
|
||||
|
||||
/** Whether real billing is wired up. False means purchases are simulated. */
|
||||
val isLive: Boolean = false
|
||||
|
||||
/**
|
||||
* Receipts paid for but not yet spent, keyed by what they were bought for.
|
||||
*
|
||||
* Registration can fail after the charge — the name was taken in between, the
|
||||
* service was down, the poll timed out. Without this the token dies with the
|
||||
* coroutine and the only way forward is to pay again for something already
|
||||
* paid for. Holding it means a retry re-submits the same receipt, which is
|
||||
* what makes the idempotency the real service requires reachable at all.
|
||||
*/
|
||||
private val unspent = mutableMapOf<String, String>()
|
||||
|
||||
/** Reuse a receipt already bought for [key], or buy one. */
|
||||
suspend fun purchaseFor(key: String, years: Int): Result? {
|
||||
unspent[key]?.let { return Result(it) }
|
||||
val r = purchase(years) ?: return null
|
||||
unspent[key] = r.token
|
||||
return r
|
||||
}
|
||||
|
||||
/** Called once the receipt has been accepted, so it is not offered again. */
|
||||
fun spent(key: String) {
|
||||
unspent.remove(key)
|
||||
}
|
||||
}
|
||||
+266
@@ -1051,6 +1051,167 @@ object ChatController {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Read-only: safe to call when a screen opens, unlike apiNameAddress or
|
||||
// apiNameRecoveryKey, both of which create the wallet as a side effect.
|
||||
suspend fun apiNameInfo(rh: Long?, fqdn: String): CR.NameInfo? {
|
||||
val userId = currentUserId("apiNameInfo")
|
||||
val r = sendCmd(rh, CC.ApiNameInfo(userId, fqdn))
|
||||
if (r is API.Result && r.res is CR.NameInfo) return r.res
|
||||
Log.e(TAG, "apiNameInfo bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameSetLink(rh: Long?, fqdn: String, link: String): Boolean {
|
||||
val userId = currentUserId("apiNameSetLink")
|
||||
val r = sendCmd(rh, CC.ApiNameSetLink(userId, fqdn, link))
|
||||
if (r is API.Result && r.res is CR.NameIntentRelayed) return true
|
||||
Log.e(TAG, "apiNameSetLink bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_repoint_failed), "${r.responseType}: ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiNameRenew(rh: Long?, fqdn: String, years: Int, payment: String): CR.NameRenewed? {
|
||||
val userId = currentUserId("apiNameRenew")
|
||||
val r = sendCmd(rh, CC.ApiNameRenew(userId, fqdn, years, payment))
|
||||
if (r is API.Result && r.res is CR.NameRenewed) return r.res
|
||||
Log.e(TAG, "apiNameRenew bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_renew_failed), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
// "existing" | "new" | "import <phrase>" - the core refuses if this profile
|
||||
// already has keys, so this can never displace a stored seed.
|
||||
suspend fun apiNameSetup(rh: Long?, setup: String): Boolean {
|
||||
val userId = currentUserId("apiNameSetup")
|
||||
val r = sendCmd(rh, CC.ApiNameSetup(userId, setup))
|
||||
if (r is API.Result && r.res is CR.NameStatus) return true
|
||||
Log.e(TAG, "apiNameSetup bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_setup_failed), "${r.responseType}: ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiNameStatus(rh: Long?): CR.NameStatus? {
|
||||
val userId = currentUserId("apiNameStatus")
|
||||
val r = sendCmd(rh, CC.ApiNameStatus(userId))
|
||||
if (r is API.Result && r.res is CR.NameStatus) return r.res
|
||||
Log.e(TAG, "apiNameStatus bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameIncoming(rh: Long?): List<IncomingName>? {
|
||||
val userId = currentUserId("apiNameIncoming")
|
||||
val r = sendCmd(rh, CC.ApiNameIncoming(userId))
|
||||
if (r is API.Result && r.res is CR.NamesIncoming) return r.res.incomingNames
|
||||
Log.e(TAG, "apiNameIncoming bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameRescan(rh: Long?): Int? {
|
||||
val userId = currentUserId("apiNameRescan")
|
||||
val r = sendCmd(rh, CC.ApiNameRescan(userId))
|
||||
if (r is API.Result && r.res is CR.NameRescanned) return r.res.rescanFound
|
||||
Log.e(TAG, "apiNameRescan bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameAccept(rh: Long?, address: String): List<String>? {
|
||||
val userId = currentUserId("apiNameAccept")
|
||||
val r = sendCmd(rh, CC.ApiNameAccept(userId, address))
|
||||
if (r is API.Result && r.res is CR.NameAccepted) return r.res.acceptedNames
|
||||
Log.e(TAG, "apiNameAccept bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_accept_failed), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameDecline(rh: Long?, address: String): Boolean {
|
||||
val userId = currentUserId("apiNameDecline")
|
||||
val r = sendCmd(rh, CC.ApiNameDecline(userId, address))
|
||||
if (r is API.Result && r.res is CR.NameDeclined) return true
|
||||
Log.e(TAG, "apiNameDecline bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_decline_failed), "${r.responseType}: ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
// The key for one received name. Discloses that address and nothing else -
|
||||
// not the recovery key, not the profile's other names.
|
||||
suspend fun apiNameExportKey(rh: Long?, address: String): String? {
|
||||
val userId = currentUserId("apiNameExportKey")
|
||||
val r = sendCmd(rh, CC.ApiNameExportKey(userId, address))
|
||||
if (r is API.Result && r.res is CR.NameKeyExported) return r.res.nameOneTimeKey
|
||||
Log.e(TAG, "apiNameExportKey bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameAddress(rh: Long?): CR.NameAddress? {
|
||||
val userId = currentUserId("apiNameAddress")
|
||||
val r = sendCmd(rh, CC.ApiNameAddress(userId))
|
||||
if (r is API.Result && r.res is CR.NameAddress) return r.res
|
||||
Log.e(TAG, "apiNameAddress bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameRecoveryKey(rh: Long?): CR.NameRecoveryKey? {
|
||||
val userId = currentUserId("apiNameRecoveryKey")
|
||||
val r = sendCmd(rh, CC.ApiNameRecoveryKey(userId))
|
||||
if (r is API.Result && r.res is CR.NameRecoveryKey) return r.res
|
||||
Log.e(TAG, "apiNameRecoveryKey bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
// Binds this profile to an existing recovery key. The core creates the seed
|
||||
// and binds the profile to it; finding what the key already owns is a
|
||||
// separate rescan, because it needs the chain rather than the phrase.
|
||||
suspend fun apiNameRecoveryKeyImport(rh: Long?, phrase: String): Boolean {
|
||||
val userId = currentUserId("apiNameRecoveryKeyImport")
|
||||
val r = sendCmd(rh, CC.ApiNameRecoveryKeyImport(userId, phrase))
|
||||
if (r is API.Result && r.res is CR.NameRecoveryKey) return true
|
||||
Log.e(TAG, "apiNameRecoveryKeyImport bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_import_failed), "${r.responseType}: ${r.details}")
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun apiNameRecoveryKeySaved(rh: Long?): Boolean {
|
||||
val userId = currentUserId("apiNameRecoveryKeySaved")
|
||||
val r = sendCmd(rh, CC.ApiNameRecoveryKeySaved(userId))
|
||||
return r is API.Result && r.res is CR.NameRecoveryKey
|
||||
}
|
||||
|
||||
suspend fun apiNameQuote(rh: Long?, label: String): CR.NameQuoted? {
|
||||
val userId = currentUserId("apiNameQuote")
|
||||
val r = sendCmd(rh, CC.ApiNameQuote(userId, label))
|
||||
if (r is API.Result && r.res is CR.NameQuoted) return r.res
|
||||
Log.e(TAG, "apiNameQuote bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
// The payment proof is obtained by the caller before this is called: the core
|
||||
// forwards it and never mints one.
|
||||
suspend fun apiNameBuy(rh: Long?, label: String, years: Int, payment: String, link: String?): CR.NameRegistered? {
|
||||
val userId = currentUserId("apiNameBuy")
|
||||
val r = sendCmd(rh, CC.ApiNameBuy(userId, label, years, payment, link))
|
||||
if (r is API.Result && r.res is CR.NameRegistered) return r.res
|
||||
Log.e(TAG, "apiNameBuy bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_buy_failed), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameList(rh: Long?): List<OwnedName>? {
|
||||
val userId = currentUserId("apiNameList")
|
||||
val r = sendCmd(rh, CC.ApiNameList(userId))
|
||||
if (r is API.Result && r.res is CR.NamesOwned) return r.res.ownedNames
|
||||
Log.e(TAG, "apiNameList bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiNameGift(rh: Long?, label: String, recipient: String): CR.NameGifted? {
|
||||
val userId = currentUserId("apiNameGift")
|
||||
val r = sendCmd(rh, CC.ApiNameGift(userId, label, recipient))
|
||||
if (r is API.Result && r.res is CR.NameGifted) return r.res
|
||||
Log.e(TAG, "apiNameGift bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_gift_failed), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun apiGetChatTags(rh: Long?): List<ChatTag>?{
|
||||
val userId = currentUserId("apiGetChatTags")
|
||||
val r = sendCmd(rh, CC.ApiGetChatTags(userId))
|
||||
@@ -2900,6 +3061,12 @@ object ChatController {
|
||||
r.chatItems.forEach { chatItem ->
|
||||
val cInfo = chatItem.chatInfo
|
||||
val cItem = chatItem.chatItem
|
||||
// A name arriving is the one incoming event with no other signal, so
|
||||
// the settings badge is bumped here rather than only when the names
|
||||
// screen is opened - otherwise it can never draw the user to a name.
|
||||
if (!cItem.chatDir.sent && cItem.content.msgContent is MsgContent.MCAssetTransfer) {
|
||||
chatModel.namesWaiting.value = chatModel.namesWaiting.value + 1
|
||||
}
|
||||
if (active(r.user)) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.addChatItem(rhId, cInfo, cItem)
|
||||
@@ -3794,6 +3961,24 @@ sealed class CC {
|
||||
class ApiSaveSettings(val settings: AppSettings): CC()
|
||||
class ApiGetSettings(val settings: AppSettings): CC()
|
||||
class ApiGetChatTags(val userId: Long): CC()
|
||||
class ApiNameAddress(val userId: Long): CC()
|
||||
class ApiNameStatus(val userId: Long): CC()
|
||||
class ApiNameSetup(val userId: Long, val setup: String): CC()
|
||||
class ApiNameRenew(val userId: Long, val fqdn: String, val years: Int, val payment: String): CC()
|
||||
class ApiNameInfo(val userId: Long, val fqdn: String): CC()
|
||||
class ApiNameSetLink(val userId: Long, val fqdn: String, val link: String): CC()
|
||||
class ApiNameIncoming(val userId: Long): CC()
|
||||
class ApiNameAccept(val userId: Long, val address: String): CC()
|
||||
class ApiNameDecline(val userId: Long, val address: String): CC()
|
||||
class ApiNameExportKey(val userId: Long, val address: String): CC()
|
||||
class ApiNameRescan(val userId: Long): CC()
|
||||
class ApiNameGift(val userId: Long, val label: String, val recipient: String): CC()
|
||||
class ApiNameRecoveryKey(val userId: Long): CC()
|
||||
class ApiNameRecoveryKeySaved(val userId: Long): CC()
|
||||
class ApiNameRecoveryKeyImport(val userId: Long, val phrase: String): CC()
|
||||
class ApiNameList(val userId: Long): CC()
|
||||
class ApiNameQuote(val userId: Long, val label: String): CC()
|
||||
class ApiNameBuy(val userId: Long, val label: String, val years: Int, val payment: String, val link: String?): CC()
|
||||
class ApiGetChats(val userId: Long): CC()
|
||||
class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC()
|
||||
class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC()
|
||||
@@ -3983,6 +4168,24 @@ sealed class CC {
|
||||
is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}"
|
||||
is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}"
|
||||
is ApiGetChatTags -> "/_get tags $userId"
|
||||
is ApiNameAddress -> "/_name address $userId"
|
||||
is ApiNameStatus -> "/_name status $userId"
|
||||
is ApiNameSetup -> "/_name setup $userId $setup"
|
||||
is ApiNameRenew -> "/_name renew $userId $fqdn $years $payment"
|
||||
is ApiNameInfo -> "/_name info $userId $fqdn"
|
||||
is ApiNameSetLink -> "/_name link $userId $fqdn $link"
|
||||
is ApiNameIncoming -> "/_name incoming $userId"
|
||||
is ApiNameAccept -> "/_name accept $userId $address"
|
||||
is ApiNameDecline -> "/_name decline $userId $address"
|
||||
is ApiNameExportKey -> "/_name export $userId $address"
|
||||
is ApiNameRescan -> "/_name rescan $userId"
|
||||
is ApiNameGift -> "/_name gift $userId $label $recipient"
|
||||
is ApiNameRecoveryKey -> "/_name key $userId"
|
||||
is ApiNameRecoveryKeySaved -> "/_name key saved $userId"
|
||||
is ApiNameRecoveryKeyImport -> "/_name key import $userId $phrase"
|
||||
is ApiNameList -> "/_name list $userId"
|
||||
is ApiNameQuote -> "/_name quote $userId $label"
|
||||
is ApiNameBuy -> "/_name buy $userId $label $years $payment${if (link != null) " $link" else ""}"
|
||||
is ApiGetChats -> "/_get chats $userId pcc=on"
|
||||
is ApiGetChat -> {
|
||||
val tag = if (contentTag == null) {
|
||||
@@ -4194,6 +4397,24 @@ sealed class CC {
|
||||
is ApiSaveSettings -> "apiSaveSettings"
|
||||
is ApiGetSettings -> "apiGetSettings"
|
||||
is ApiGetChatTags -> "apiGetChatTags"
|
||||
is ApiNameAddress -> "apiNameAddress"
|
||||
is ApiNameStatus -> "apiNameStatus"
|
||||
is ApiNameSetup -> "apiNameSetup"
|
||||
is ApiNameRenew -> "apiNameRenew"
|
||||
is ApiNameInfo -> "apiNameInfo"
|
||||
is ApiNameSetLink -> "apiNameSetLink"
|
||||
is ApiNameIncoming -> "apiNameIncoming"
|
||||
is ApiNameAccept -> "apiNameAccept"
|
||||
is ApiNameDecline -> "apiNameDecline"
|
||||
is ApiNameExportKey -> "apiNameExportKey"
|
||||
is ApiNameRescan -> "apiNameRescan"
|
||||
is ApiNameGift -> "apiNameGift"
|
||||
is ApiNameRecoveryKey -> "apiNameRecoveryKey"
|
||||
is ApiNameRecoveryKeySaved -> "apiNameRecoveryKeySaved"
|
||||
is ApiNameRecoveryKeyImport -> "apiNameRecoveryKeyImport"
|
||||
is ApiNameList -> "apiNameList"
|
||||
is ApiNameQuote -> "apiNameQuote"
|
||||
is ApiNameBuy -> "apiNameBuy"
|
||||
is ApiGetChats -> "apiGetChats"
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiGetChatContentTypes -> "apiGetChatContentTypes"
|
||||
@@ -6579,6 +6800,21 @@ sealed class CR {
|
||||
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
|
||||
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
|
||||
@Serializable @SerialName("nameAddress") class NameAddress(val user: User, val nameAddress: String, val nameAccount: Int, val nameMetaAddress: String): CR()
|
||||
@Serializable @SerialName("nameInfo") class NameInfo(val user: User, val nameFqdn: String, val nameOwner: String, val nameContact: List<String>, val nameChannel: List<String>, val nameExpires: Int, val nameEditCredits: Int): CR()
|
||||
@Serializable @SerialName("nameRenewed") class NameRenewed(val user: User, val nameFqdn: String, val nameExpires: Int, val nameReRegistered: Boolean): CR()
|
||||
@Serializable @SerialName("nameStatus") class NameStatus(val user: User, val nameHasWallet: Boolean, val nameKeySaved: Boolean, val nameAnySeed: Boolean = false): CR()
|
||||
@Serializable @SerialName("namesIncoming") class NamesIncoming(val user: User, val incomingNames: List<IncomingName>): CR()
|
||||
@Serializable @SerialName("nameAccepted") class NameAccepted(val user: User, val nameOneTimeAddr: String, val acceptedNames: List<String>): CR()
|
||||
@Serializable @SerialName("nameDeclined") class NameDeclined(val user: User, val nameOneTimeAddr: String): CR()
|
||||
@Serializable @SerialName("nameKeyExported") class NameKeyExported(val user: User, val nameOneTimeAddr: String, val nameOneTimeKey: String): CR()
|
||||
@Serializable @SerialName("nameRescanned") class NameRescanned(val user: User, val rescanFound: Int): CR()
|
||||
@Serializable @SerialName("nameRecoveryKey") class NameRecoveryKey(val user: User, val recoveryPhrase: String, val recoveryKeySaved: Boolean): CR()
|
||||
@Serializable @SerialName("namesOwned") class NamesOwned(val user: User, val ownedNames: List<OwnedName>): CR()
|
||||
@Serializable @SerialName("nameQuoted") class NameQuoted(val user: User, val nameLabel: String, val nameAvailable: Boolean, val namePriceCents: Int): CR()
|
||||
@Serializable @SerialName("nameRegistered") class NameRegistered(val user: User, val nameFqdn: String, val nameTxHash: String): CR()
|
||||
@Serializable @SerialName("nameGifted") class NameGifted(val user: User, val nameFqdn: String, val nameTxHash: String, val nameEphemeralPubKey: String): CR()
|
||||
@Serializable @SerialName("nameIntentRelayed") class NameIntentRelayed(val user: User, val nameAction: String, val nameFqdn: String, val nameTxHash: String): CR()
|
||||
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
|
||||
@@ -6773,6 +7009,21 @@ sealed class CR {
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is NameAddress -> "nameAddress"
|
||||
is NamesIncoming -> "namesIncoming"
|
||||
is NameStatus -> "nameStatus"
|
||||
is NameRenewed -> "nameRenewed"
|
||||
is NameInfo -> "nameInfo"
|
||||
is NameAccepted -> "nameAccepted"
|
||||
is NameDeclined -> "nameDeclined"
|
||||
is NameKeyExported -> "nameKeyExported"
|
||||
is NameRescanned -> "nameRescanned"
|
||||
is NameIntentRelayed -> "nameIntentRelayed"
|
||||
is NameGifted -> "nameGifted"
|
||||
is NameRecoveryKey -> "nameRecoveryKey"
|
||||
is NamesOwned -> "namesOwned"
|
||||
is NameQuoted -> "nameQuoted"
|
||||
is NameRegistered -> "nameRegistered"
|
||||
is UserPrivacy -> "userPrivacy"
|
||||
is ContactAliasUpdated -> "contactAliasUpdated"
|
||||
is GroupAliasUpdated -> "groupAliasUpdated"
|
||||
@@ -6959,6 +7210,21 @@ sealed class CR {
|
||||
is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
|
||||
is UserProfileNoChange -> withUser(user, noDetails())
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
is NameAddress -> withUser(user, "address: $nameAddress\naccount: $nameAccount\nmeta: $nameMetaAddress")
|
||||
is NamesIncoming -> withUser(user, json.encodeToString(incomingNames))
|
||||
is NameStatus -> withUser(user, "wallet: $nameHasWallet, key saved: $nameKeySaved")
|
||||
is NameRenewed -> withUser(user, "$nameFqdn expires $nameExpires, re-registered: $nameReRegistered")
|
||||
is NameInfo -> withUser(user, "$nameFqdn expires $nameExpires, credits $nameEditCredits")
|
||||
is NameAccepted -> withUser(user, "$nameOneTimeAddr: ${acceptedNames.joinToString(", ")}")
|
||||
is NameDeclined -> withUser(user, nameOneTimeAddr)
|
||||
is NameKeyExported -> withUser(user, nameOneTimeAddr)
|
||||
is NameRescanned -> withUser(user, rescanFound.toString())
|
||||
is NameIntentRelayed -> withUser(user, "$nameAction $nameFqdn: $nameTxHash")
|
||||
is NameGifted -> withUser(user, "$nameFqdn: $nameTxHash")
|
||||
is NameRecoveryKey -> withUser(user, "saved: $recoveryKeySaved")
|
||||
is NamesOwned -> withUser(user, ownedNames.joinToString(", ") { it.onFqdn })
|
||||
is NameQuoted -> withUser(user, "$nameLabel available: $nameAvailable, $namePriceCents cents")
|
||||
is NameRegistered -> withUser(user, "$nameFqdn: $nameTxHash")
|
||||
is UserPrivacy -> withUser(user, json.encodeToString(updatedUser))
|
||||
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup))
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
/**
|
||||
* Offer to save a reminder for a date, using whatever the platform provides.
|
||||
*
|
||||
* Nothing renews automatically and the app sends no expiry notifications, so
|
||||
* this is how a user is actually reminded before a name lapses: the reminder
|
||||
* lives in their calendar, which survives losing the device.
|
||||
*
|
||||
* Returns false when the platform has nowhere to put it, so the caller can say
|
||||
* so rather than appearing to have done something.
|
||||
*/
|
||||
/**
|
||||
* @param remindDaysBefore how long before [epochSeconds] to alert. A reminder
|
||||
* on the day itself is too late to be useful: extending needs a purchase, and
|
||||
* a name that lapses becomes available to anyone.
|
||||
*/
|
||||
expect fun addCalendarReminder(title: String, description: String, epochSeconds: Long, remindDaysBefore: Int = 7): Boolean
|
||||
+3
@@ -263,6 +263,8 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
return when (val mc = chatItem.content.msgContent) {
|
||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||
// A transfer is not composed or edited by hand, so it previews as nothing.
|
||||
is MsgContent.MCAssetTransfer -> ComposePreview.NoPreview
|
||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||
// TODO: include correct type
|
||||
is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||
@@ -728,6 +730,7 @@ fun ComposeView(
|
||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||
return when (msgContent) {
|
||||
is MsgContent.MCText -> checkLinkPreview()
|
||||
is MsgContent.MCAssetTransfer -> msgContent
|
||||
is MsgContent.MCLink -> checkLinkPreview()
|
||||
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
|
||||
|
||||
+21
@@ -1,6 +1,7 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -23,6 +24,7 @@ import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.SimplexNamesView
|
||||
import chat.simplex.common.views.newchat.planAndConnect
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -366,6 +368,25 @@ fun FramedItemView(
|
||||
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr)
|
||||
}
|
||||
}
|
||||
// Rendered from the payload, in the reader's language: the text
|
||||
// the sender composed is only a fallback for older clients.
|
||||
is MsgContent.MCAssetTransfer -> {
|
||||
// The message says to open SimpleX names, so it takes you
|
||||
// there: the alternative is four manual steps from a bubble
|
||||
// the user may never see again.
|
||||
Text(
|
||||
assetTransferText(mc.transfer, ci.chatDir.sent),
|
||||
style = MaterialTheme.typography.body1,
|
||||
color = if (ci.chatDir.sent) Color.Unspecified else MaterialTheme.colors.primary,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = DEFAULT_PADDING_HALF * 1.5f, vertical = DEFAULT_PADDING_HALF)
|
||||
.clickable(enabled = !ci.chatDir.sent) {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
SimplexNamesView(chatModel.remoteHostId(), close)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is MsgContent.MCReport -> {
|
||||
val prefix = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
|
||||
+13
@@ -108,10 +108,23 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin
|
||||
|
||||
// Full display text for a chat item — joins segment display texts.
|
||||
fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String {
|
||||
// A transfer carries data, not prose: the wording is chosen here, in the
|
||||
// reader's language, rather than in whatever language the sender was using.
|
||||
val mc = ci.content.msgContent
|
||||
if (mc is MsgContent.MCAssetTransfer) return assetTransferText(mc.transfer, ci.chatDir.sent)
|
||||
val formattedText = ci.formattedText ?: return ci.text
|
||||
return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) }
|
||||
}
|
||||
|
||||
// Wording for a transfer, by kind. An unknown kind falls back to the sender's
|
||||
// text, which is what keeps older and newer clients readable to each other.
|
||||
fun assetTransferText(t: AssetTransfer, sent: Boolean): String = when (t.kind) {
|
||||
AssetTransfer.KIND_SIMPLEX_NAME ->
|
||||
if (sent) generalGetString(MR.strings.names_transfer_sent).format(t.asset)
|
||||
else generalGetString(MR.strings.names_transfer_received).format(t.asset)
|
||||
else -> generalGetString(MR.strings.names_transfer_unknown)
|
||||
}
|
||||
|
||||
// Display-only prefix rendered before ci.text (e.g. "Spam: " for reports).
|
||||
// Renderers and selection code MUST share this string — otherwise selection offsets drift from screen.
|
||||
fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) {
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Buying a name.
|
||||
//
|
||||
// Three steps, in the order the user experiences them: choose a name, see
|
||||
// whether it is free and what it costs, pay. Payment goes through
|
||||
// [NamePayment], which is the only place that knows about stores - when real
|
||||
// billing lands, nothing on this screen changes.
|
||||
//
|
||||
// A name is bought outright for a fixed term. There is no subscription, so the
|
||||
// screen says when it runs out rather than implying it renews itself.
|
||||
@Composable
|
||||
fun BuyNameView(rhId: Long?, close: () -> Unit) {
|
||||
val label = rememberSaveable { mutableStateOf("") }
|
||||
val years = rememberSaveable { mutableStateOf(1) }
|
||||
val quote = remember { mutableStateOf<CR.NameQuoted?>(null) }
|
||||
val checking = remember { mutableStateOf(false) }
|
||||
val buying = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val myLink = chatModel.userAddress.value?.connLinkContact?.simplexChatUri(short = true)
|
||||
// A name can be bought as a gift, so pointing it here is a default, not a
|
||||
// rule. Off means it is registered pointing nowhere, ready to be given away.
|
||||
val pointAtMe = rememberSaveable { mutableStateOf(true) }
|
||||
// Without an address there is nothing to point at, and leaving the switch on
|
||||
// would register the name pointing nowhere while claiming otherwise.
|
||||
LaunchedEffect(myLink) { if (myLink == null) pointAtMe.value = false }
|
||||
|
||||
// Re-quote as the user types, but only once they have stopped.
|
||||
LaunchedEffect(label.value) {
|
||||
quote.value = null
|
||||
val l = label.value.trim().lowercase()
|
||||
if (l.length < 6) return@LaunchedEffect
|
||||
delay(400)
|
||||
checking.value = true
|
||||
quote.value = chatModel.controller.apiNameQuote(rhId, l)
|
||||
checking.value = false
|
||||
}
|
||||
|
||||
fun doBuy() {
|
||||
scope.launch {
|
||||
buying.value = true
|
||||
try {
|
||||
val label = label.value.trim().lowercase()
|
||||
// Keyed on what is being bought, so a retry after a failed
|
||||
// registration reuses the receipt instead of charging again.
|
||||
val paid = NamePayment.purchaseFor("buy:$rhId:$label:${years.value}", years.value)
|
||||
if (paid == null) return@launch // cancelled: nothing charged, nothing registered
|
||||
// Bought to be used, so it points at this profile from the moment it
|
||||
// exists rather than needing a second trip through the detail screen.
|
||||
val link = if (pointAtMe.value) myLink else null
|
||||
val reg = chatModel.controller.apiNameBuy(rhId, label, years.value, paid.token, link)
|
||||
if (reg != null) NamePayment.spent("buy:$rhId:$label:${years.value}")
|
||||
if (reg != null) {
|
||||
// The switch already said whether this name is for this profile, so
|
||||
// it is acted on rather than asked again. Chaining dialogs here was
|
||||
// also unexitable: dismissing one pushed the next, and the dismiss
|
||||
// then popped the pushed one instead.
|
||||
var claimFailed = false
|
||||
if (pointAtMe.value) {
|
||||
try {
|
||||
chatModel.updateUser(chatModel.controller.apiSetUserDomain(rhId, reg.nameFqdn))
|
||||
} catch (e: Exception) {
|
||||
// The name is registered and points here, but the profile claim
|
||||
// failed - usually because there is no short link yet. Saying
|
||||
// "now points at this profile" here would be false.
|
||||
Log.e(TAG, "apiSetUserDomain after buy: ${e.message}")
|
||||
claimFailed = true
|
||||
}
|
||||
}
|
||||
val keySaved = chatModel.controller.apiNameStatus(rhId)?.nameKeySaved ?: false
|
||||
close()
|
||||
if (!keySaved) {
|
||||
// The only backup this profile has, and it did not exist until now.
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_bought_title),
|
||||
text = generalGetString(
|
||||
if (!pointAtMe.value) MR.strings.names_bought_for_other
|
||||
else if (claimFailed) MR.strings.names_bought_not_shown
|
||||
else MR.strings.names_bought_text
|
||||
).format(reg.nameFqdn),
|
||||
confirmText = generalGetString(MR.strings.names_bought_save_key),
|
||||
dismissText = generalGetString(MR.strings.names_bought_later),
|
||||
onConfirm = { ModalManager.start.showModalCloseable { c -> NameRecoveryKeyView(rhId, c) } },
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.names_bought_title),
|
||||
text = generalGetString(
|
||||
if (!pointAtMe.value) MR.strings.names_bought_for_other
|
||||
else if (claimFailed) MR.strings.names_bought_not_shown
|
||||
else MR.strings.names_bought_text
|
||||
).format(reg.nameFqdn),
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
buying.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BuyNameLayout(
|
||||
label = label,
|
||||
years = years,
|
||||
quote = quote.value,
|
||||
checking = checking.value,
|
||||
buying = buying.value,
|
||||
pointAtMe = pointAtMe,
|
||||
canPoint = myLink != null,
|
||||
buy = { confirmBuy(label.value, pointAtMe.value) { doBuy() } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun confirmBuy(rawLabel: String, pointAtMe: Boolean, proceed: () -> Unit) {
|
||||
val fqdn = rawLabel.trim().lowercase() + ".simplex"
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = if (pointAtMe) generalGetString(MR.strings.names_buy_confirm_title) else generalGetString(MR.strings.names_buy_confirm_other_title),
|
||||
// Pointing it here puts your address into a public record, so that is
|
||||
// confirmed rather than assumed. Buying it for someone else does not.
|
||||
text = if (pointAtMe) generalGetString(MR.strings.names_buy_confirm_text).format(fqdn)
|
||||
else generalGetString(MR.strings.names_buy_confirm_other_text).format(fqdn),
|
||||
confirmText = generalGetString(MR.strings.names_buy_confirm_action),
|
||||
onConfirm = proceed,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuyNameLayout(
|
||||
label: MutableState<String>,
|
||||
years: MutableState<Int>,
|
||||
quote: CR.NameQuoted?,
|
||||
checking: Boolean,
|
||||
buying: Boolean,
|
||||
pointAtMe: MutableState<Boolean>,
|
||||
canPoint: Boolean,
|
||||
buy: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_buy_title))
|
||||
|
||||
SectionView(stringResource(MR.strings.names_buy_choose).uppercase()) {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextField(
|
||||
value = label.value,
|
||||
// Not filtered: silently dropping a character looks like a broken
|
||||
// keyboard. Invalid input is shown and explained instead.
|
||||
onValueChange = { label.value = it.trim().lowercase() },
|
||||
placeholder = { Text(stringResource(MR.strings.names_buy_placeholder)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(".simplex", color = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||
// Availability where the name is typed, not in a section below it.
|
||||
Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) {
|
||||
when (val st = nameStatus(label.value, checking, quote)) {
|
||||
is NameStatusUi.Empty -> {}
|
||||
is NameStatusUi.Checking -> CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
|
||||
is NameStatusUi.Available -> Icon(painterResource(MR.images.ic_check), stringResource(MR.strings.names_buy_available_a11y), tint = SimplexGreen)
|
||||
is NameStatusUi.Taken -> Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.names_buy_taken), tint = Color.Red)
|
||||
// Fixable by typing: a warning, not a rejection.
|
||||
is NameStatusUi.TooShort, is NameStatusUi.BadChars, is NameStatusUi.Unknown ->
|
||||
Icon(painterResource(MR.images.ic_error), stringResource(MR.strings.names_buy_check_a11y), tint = WarningOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when (val st = nameStatus(label.value, checking, quote)) {
|
||||
is NameStatusUi.Empty -> SectionTextFooter(stringResource(MR.strings.names_buy_rules))
|
||||
is NameStatusUi.BadChars -> SectionTextFooter(stringResource(MR.strings.names_buy_bad_chars), WarningOrange)
|
||||
is NameStatusUi.TooShort -> SectionTextFooter(stringResource(MR.strings.names_buy_too_short), WarningOrange)
|
||||
is NameStatusUi.Checking -> SectionTextFooter(stringResource(MR.strings.names_buy_checking))
|
||||
is NameStatusUi.Unknown -> SectionTextFooter(stringResource(MR.strings.names_buy_unknown), WarningOrange)
|
||||
is NameStatusUi.Taken -> SectionTextFooter(stringResource(MR.strings.names_buy_taken), Color.Red)
|
||||
is NameStatusUi.Available -> SectionTextFooter(stringResource(MR.strings.names_buy_available).format(priceText(st.priceCents, years.value)), SimplexGreen)
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.names_buy_term).uppercase()) {
|
||||
SectionItemView {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (years.value == 1) stringResource(MR.strings.names_buy_year_one)
|
||||
else stringResource(MR.strings.names_buy_years).format(years.value)
|
||||
)
|
||||
Row {
|
||||
TextButton(onClick = { if (years.value > 1) years.value-- }, enabled = years.value > 1) { Text("−") }
|
||||
TextButton(onClick = { if (years.value < 10) years.value++ }, enabled = years.value < 10) { Text("+") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_buy_no_subscription))
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(MR.strings.names_buy_point_at_me), color = if (canPoint) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||
DefaultSwitch(checked = pointAtMe.value, enabled = canPoint, onCheckedChange = { pointAtMe.value = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
when {
|
||||
!canPoint -> stringResource(MR.strings.names_buy_no_address)
|
||||
pointAtMe.value -> stringResource(MR.strings.names_buy_point_at_me_on)
|
||||
else -> stringResource(MR.strings.names_buy_point_at_me_off)
|
||||
},
|
||||
if (canPoint) MaterialTheme.colors.secondary else WarningOrange
|
||||
)
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
when {
|
||||
quote == null || !quote.nameAvailable -> {}
|
||||
else -> {
|
||||
SectionItemView(click = if (buying) null else buy, disabled = buying) {
|
||||
Text(
|
||||
if (buying) stringResource(MR.strings.names_buy_working)
|
||||
else stringResource(MR.strings.names_buy_action).format(priceText(quote.namePriceCents, years.value)),
|
||||
color = if (buying) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!NamePayment.isLive) {
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.names_buy_dev_section).uppercase()) {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_buy_dev_warning), color = WarningOrange) }
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Label rule, mirroring simplexmq's nameLabelP and SetSimplexNameView: ASCII
|
||||
// letters and digits, single internal hyphens, no leading or trailing hyphen.
|
||||
private val labelRegex = Regex("[a-z0-9]+(-[a-z0-9]+)*")
|
||||
|
||||
private sealed class NameStatusUi {
|
||||
object Empty : NameStatusUi()
|
||||
object TooShort : NameStatusUi()
|
||||
object BadChars : NameStatusUi()
|
||||
object Checking : NameStatusUi()
|
||||
object Unknown : NameStatusUi()
|
||||
object Taken : NameStatusUi()
|
||||
class Available(val priceCents: Int) : NameStatusUi()
|
||||
}
|
||||
|
||||
private fun nameStatus(raw: String, checking: Boolean, quote: CR.NameQuoted?): NameStatusUi {
|
||||
val l = raw.trim().lowercase()
|
||||
return when {
|
||||
l.isEmpty() -> NameStatusUi.Empty
|
||||
// Character rule is reported before length: "6 characters" is unhelpful
|
||||
// advice when the problem is a space or an underscore.
|
||||
!labelRegex.matches(l) -> NameStatusUi.BadChars
|
||||
l.length < 6 -> NameStatusUi.TooShort
|
||||
checking -> NameStatusUi.Checking
|
||||
quote == null -> NameStatusUi.Unknown
|
||||
quote.nameAvailable -> NameStatusUi.Available(quote.namePriceCents)
|
||||
else -> NameStatusUi.Taken
|
||||
}
|
||||
}
|
||||
|
||||
private fun priceText(centsPerYear: Int, years: Int): String {
|
||||
val total = centsPerYear * years
|
||||
return "$${total / 100}.${(total % 100).toString().padStart(2, '0')}"
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
// Giving a name to a contact.
|
||||
//
|
||||
// Only contacts whose profile carries a receiving address can be chosen: the
|
||||
// name is sent to a one-time address derived from it, which is what keeps the
|
||||
// transfer from linking the recipient to the name for everyone watching. A
|
||||
// contact on an older version, or one who has not turned receiving on, simply
|
||||
// has nowhere for it to land.
|
||||
@Composable
|
||||
fun GiveNameView(rhId: Long?, fqdn: String, close: () -> Unit) {
|
||||
val busy = remember { mutableStateOf(false) }
|
||||
val label = fqdn.removeSuffix(".simplex")
|
||||
|
||||
val candidates = chatModel.chats.value.mapNotNull { c ->
|
||||
val ci = c.chatInfo
|
||||
if (ci is ChatInfo.Direct) ci.contact else null
|
||||
}
|
||||
val canReceive = candidates.filter { it.profile.metaAddress != null }
|
||||
val cannotReceive = candidates.size - canReceive.size
|
||||
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_give_title))
|
||||
SectionTextFooter(stringResource(MR.strings.names_give_intro).format(fqdn))
|
||||
|
||||
SectionView(stringResource(MR.strings.names_give_choose).uppercase()) {
|
||||
if (canReceive.isEmpty()) {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_give_nobody), color = MaterialTheme.colors.secondary) }
|
||||
} else {
|
||||
canReceive.forEach { ct ->
|
||||
SectionItemView(click = {
|
||||
if (!busy.value) confirmGive(rhId, label, fqdn, ct, busy) { close() }
|
||||
}, disabled = busy.value) {
|
||||
Text(ct.profile.profileViewName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (cannotReceive == 1) stringResource(MR.strings.names_give_one_cannot)
|
||||
else if (cannotReceive > 0) stringResource(MR.strings.names_give_some_cannot).format(cannotReceive)
|
||||
else stringResource(MR.strings.names_give_footer)
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmGive(rhId: Long?, label: String, fqdn: String, ct: Contact, busy: MutableState<Boolean>, onDone: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_give_confirm_title),
|
||||
// Irreversible without their cooperation: after this they own it, not you.
|
||||
text = generalGetString(MR.strings.names_give_confirm_text).format(fqdn, ct.profile.profileViewName),
|
||||
confirmText = generalGetString(MR.strings.names_give_action),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
busy.value = true
|
||||
try {
|
||||
// localDisplayName, not profile.displayName: the core resolves this
|
||||
// against local_display_name, which is deduplicated. Two contacts
|
||||
// called "alice" would otherwise send the name to whichever one the
|
||||
// core happened to match, irreversibly.
|
||||
val gifted = chatModel.controller.apiNameGift(rhId, label, "@${ct.localDisplayName}")
|
||||
if (gifted != null) {
|
||||
// The core sends the transfer message itself, so every client
|
||||
// behaves the same and this does not send a second one.
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.names_give_sent_title),
|
||||
text = generalGetString(MR.strings.names_give_sent_text).format(fqdn, ct.profile.profileViewName),
|
||||
)
|
||||
onDone()
|
||||
}
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Restoring names from a recovery key.
|
||||
//
|
||||
// This is the path that makes the whole design true: a name survives losing the
|
||||
// device, because the key that owns it comes back from twelve words. Importing
|
||||
// binds this profile to that key, then a scan finds what it already owns —
|
||||
// buying nothing and asking no one.
|
||||
@Composable
|
||||
fun ImportRecoveryKeyView(rhId: Long?, close: () -> Unit) {
|
||||
val phrase = rememberSaveable { mutableStateOf("") }
|
||||
val working = remember { mutableStateOf(false) }
|
||||
val hasWallet = remember { mutableStateOf<Boolean?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
hasWallet.value = chatModel.controller.apiNameStatus(rhId)?.nameHasWallet ?: false
|
||||
}
|
||||
|
||||
fun doImport() {
|
||||
scope.launch {
|
||||
working.value = true
|
||||
try {
|
||||
if (!chatModel.controller.apiNameRecoveryKeyImport(rhId, phrase.value.trim())) return@launch
|
||||
// The key says which names are ours; the chain says which exist. Only
|
||||
// a scan joins the two, so it runs here rather than making the user
|
||||
// discover an empty list and go looking for a button.
|
||||
val found = chatModel.controller.apiNameRescan(rhId) ?: 0
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.names_import_done_title),
|
||||
text = if (found == 0) generalGetString(MR.strings.names_import_done_none)
|
||||
else generalGetString(MR.strings.names_import_done_found).format(found),
|
||||
)
|
||||
close()
|
||||
} finally {
|
||||
working.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val words = phrase.value.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||
val looksComplete = words.size == 12 || words.size == 15 || words.size == 18 || words.size == 21 || words.size == 24
|
||||
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_import_title))
|
||||
|
||||
if (hasWallet.value == true) {
|
||||
// The core refuses to import over an existing key, so say so plainly
|
||||
// rather than warn about a replacement that will not happen.
|
||||
SectionView {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_import_already), color = MaterialTheme.colors.secondary) }
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
return@ColumnWithScrollBar
|
||||
}
|
||||
|
||||
SectionTextFooter(stringResource(MR.strings.names_import_intro))
|
||||
|
||||
SectionView(stringResource(MR.strings.names_import_section).uppercase()) {
|
||||
SectionItemView(padding = PaddingValues(DEFAULT_PADDING)) {
|
||||
TextField(
|
||||
value = phrase.value,
|
||||
onValueChange = { phrase.value = it },
|
||||
placeholder = { Text(stringResource(MR.strings.names_import_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !working.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
if (phrase.value.isBlank()) stringResource(MR.strings.names_import_hint)
|
||||
else if (words.size == 1) stringResource(MR.strings.names_import_word_count_one)
|
||||
else stringResource(MR.strings.names_import_word_count).format(words.size),
|
||||
if (phrase.value.isBlank() || looksComplete) MaterialTheme.colors.secondary else WarningOrange
|
||||
)
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
SectionItemView(
|
||||
click = if (working.value || !looksComplete) null else { { doImport() } },
|
||||
disabled = working.value || !looksComplete,
|
||||
) {
|
||||
Text(
|
||||
if (working.value) stringResource(MR.strings.names_import_working) else stringResource(MR.strings.names_import_action),
|
||||
color = if (working.value || !looksComplete) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Names other people have sent to this profile.
|
||||
//
|
||||
// A name lands at a one-time address that only this profile can find, and stays
|
||||
// there until the user decides. Accepting is what links this profile to the
|
||||
// name on chain, and it costs a purchase; declining writes nothing and leaves
|
||||
// no trace. Both facts belong on the screen before either button is pressed,
|
||||
// not in a confirmation afterwards.
|
||||
@Composable
|
||||
fun IncomingNamesView(rhId: Long?, close: () -> Unit) {
|
||||
val incoming = remember { mutableStateOf(emptyList<IncomingName>()) }
|
||||
val loading = remember { mutableStateOf(true) }
|
||||
val canReceive = remember { mutableStateOf(true) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
// Read-only: opening this screen must not create keys by itself.
|
||||
canReceive.value = chatModel.controller.apiNameStatus(rhId)?.nameHasWallet ?: false
|
||||
incoming.value = if (canReceive.value) chatModel.controller.apiNameIncoming(rhId) ?: emptyList() else emptyList()
|
||||
chatModel.namesWaiting.value = incoming.value.size
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
IncomingNamesLayout(
|
||||
incoming = incoming.value,
|
||||
loading = loading.value,
|
||||
canReceive = canReceive.value,
|
||||
enableReceiving = {
|
||||
scope.launch {
|
||||
loading.value = true
|
||||
// Creates the wallet and publishes the receiving address to contacts.
|
||||
val addr = chatModel.controller.apiNameAddress(rhId)
|
||||
reload()
|
||||
if (addr == null) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_setup_failed), "")
|
||||
} else {
|
||||
// A recovery key now exists and is unsaved, and it is the only way
|
||||
// back to anything received here.
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_receive_enabled_title),
|
||||
text = generalGetString(MR.strings.names_receive_enabled_text),
|
||||
confirmText = generalGetString(MR.strings.names_bought_save_key),
|
||||
dismissText = generalGetString(MR.strings.names_bought_later),
|
||||
onConfirm = { ModalManager.start.showModalCloseable { c -> NameRecoveryKeyView(rhId, c) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
rescan = {
|
||||
scope.launch {
|
||||
loading.value = true
|
||||
val found = chatModel.controller.apiNameRescan(rhId)
|
||||
reload()
|
||||
if (found != null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.names_rescan_done_title),
|
||||
text = when (found) {
|
||||
0 -> generalGetString(MR.strings.names_rescan_none)
|
||||
1 -> generalGetString(MR.strings.names_rescan_found_one)
|
||||
else -> generalGetString(MR.strings.names_rescan_found).format(found)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
accept = { item -> confirmAccept(rhId, item) { scope.launch { reload() } } },
|
||||
decline = { item -> confirmDecline(rhId, item) { scope.launch { reload() } } },
|
||||
close = close,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingNamesLayout(
|
||||
incoming: List<IncomingName>,
|
||||
loading: Boolean,
|
||||
canReceive: Boolean,
|
||||
enableReceiving: () -> Unit,
|
||||
rescan: () -> Unit,
|
||||
accept: (IncomingName) -> Unit,
|
||||
decline: (IncomingName) -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_incoming_title))
|
||||
|
||||
if (!canReceive && !loading) {
|
||||
// Without a wallet there is nowhere for a name to be sent, so this is the
|
||||
// one thing worth offering.
|
||||
SectionView {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_receive_off), color = MaterialTheme.colors.secondary) }
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.names_receive_enable),
|
||||
enableReceiving,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_receive_off_footer))
|
||||
} else if (incoming.isEmpty()) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Text(
|
||||
if (loading) stringResource(MR.strings.names_incoming_loading)
|
||||
else stringResource(MR.strings.names_incoming_none),
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_incoming_ready_footer))
|
||||
} else {
|
||||
SectionView(stringResource(MR.strings.names_incoming_section).uppercase()) {
|
||||
incoming.forEach { item ->
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(item.inNames.joinToString(", "), fontWeight = androidx.compose.ui.text.font.FontWeight.Medium)
|
||||
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)) {
|
||||
TextButton(onClick = { accept(item) }) {
|
||||
Text(stringResource(MR.strings.names_incoming_accept), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
TextButton(onClick = { decline(item) }) {
|
||||
Text(stringResource(MR.strings.names_incoming_decline), color = Color.Red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The trade-off, stated before either button rather than after.
|
||||
SectionTextFooter(stringResource(MR.strings.names_incoming_footer))
|
||||
}
|
||||
|
||||
if (canReceive) {
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_search),
|
||||
stringResource(MR.strings.names_rescan),
|
||||
rescan,
|
||||
disabled = loading,
|
||||
)
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_rescan_footer))
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmAccept(rhId: Long?, item: IncomingName, onDone: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_accept_title),
|
||||
text = generalGetString(MR.strings.names_accept_text).format(item.inNames.joinToString(", ")),
|
||||
confirmText = generalGetString(MR.strings.names_incoming_accept),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
val accepted = chatModel.controller.apiNameAccept(rhId, item.inAddress)
|
||||
onDone()
|
||||
// The dialog says the name can be shown on the profile afterwards, so
|
||||
// offer it here rather than leaving the user on an empty screen to
|
||||
// work out where the name went.
|
||||
accepted?.firstOrNull()?.let { offerSetPrimaryName(rhId, it) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun confirmDecline(rhId: Long?, item: IncomingName, onDone: () -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_decline_title),
|
||||
text = generalGetString(MR.strings.names_decline_text),
|
||||
confirmText = generalGetString(MR.strings.names_incoming_decline),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
chatModel.controller.apiNameDecline(rhId, item.inAddress)
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
// One name: when it runs out, where it points, and what can be done with it.
|
||||
//
|
||||
// The link a name points at is the whole point of owning one, so a mismatch
|
||||
// with the profile's current address is called out rather than left for the
|
||||
// user to compare by eye.
|
||||
@Composable
|
||||
fun NameDetailView(rhId: Long?, fqdn: String, close: () -> Unit) {
|
||||
val info = remember { mutableStateOf<CR.NameInfo?>(null) }
|
||||
val loadFailed = remember { mutableStateOf(false) }
|
||||
val busy = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
suspend fun reload() {
|
||||
val r = chatModel.controller.apiNameInfo(rhId, fqdn)
|
||||
info.value = r
|
||||
loadFailed.value = r == null
|
||||
}
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
val myLink = chatModel.userAddress.value?.connLinkContact?.simplexChatUri(short = true)
|
||||
|
||||
NameDetailLayout(
|
||||
fqdn = fqdn,
|
||||
info = info.value,
|
||||
loadFailed = loadFailed.value,
|
||||
myLink = myLink,
|
||||
busy = busy.value,
|
||||
repoint = {
|
||||
if (myLink != null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_repoint_title),
|
||||
text = generalGetString(MR.strings.names_repoint_text).format(fqdn),
|
||||
confirmText = generalGetString(MR.strings.names_repoint_action),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
busy.value = true
|
||||
try {
|
||||
if (chatModel.controller.apiNameSetLink(rhId, fqdn, myLink)) {
|
||||
reload()
|
||||
// Now that it points here, offer to show it on the profile.
|
||||
if (chatModel.currentUser.value?.profile?.contactDomain?.domain != fqdn) {
|
||||
offerSetPrimaryName(rhId, fqdn)
|
||||
}
|
||||
}
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
renew = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_renew_title),
|
||||
text = generalGetString(MR.strings.names_renew_text).format(fqdn),
|
||||
confirmText = generalGetString(MR.strings.names_renew_action),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
busy.value = true
|
||||
try {
|
||||
val paid = NamePayment.purchaseFor("renew:$rhId:$fqdn", 1) ?: return@withBGApi
|
||||
val r = chatModel.controller.apiNameRenew(rhId, fqdn, 1, paid.token)
|
||||
if (r != null) NamePayment.spent("renew:$rhId:$fqdn")
|
||||
if (r != null) {
|
||||
reload()
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.names_renew_done_title),
|
||||
text = generalGetString(
|
||||
if (r.nameReRegistered) MR.strings.names_renew_done_again else MR.strings.names_renew_done
|
||||
).format(fqdn),
|
||||
)
|
||||
}
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
gift = { ModalManager.start.showModalCloseable { c -> GiveNameView(rhId, fqdn, c) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameDetailLayout(
|
||||
fqdn: String,
|
||||
info: CR.NameInfo?,
|
||||
loadFailed: Boolean,
|
||||
myLink: String?,
|
||||
busy: Boolean,
|
||||
repoint: () -> Unit,
|
||||
renew: () -> Unit,
|
||||
gift: () -> Unit,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(fqdn)
|
||||
if (info != null && expiryDays(info.nameExpires.toLong()) < 0) {
|
||||
SectionTextFooter(stringResource(MR.strings.names_expired_note), Color.Red)
|
||||
}
|
||||
|
||||
if (info == null) {
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Text(
|
||||
if (loadFailed) stringResource(MR.strings.names_list_load_failed) else stringResource(MR.strings.names_incoming_loading),
|
||||
color = if (loadFailed) WarningOrange else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
SectionBottomSpacer()
|
||||
return@ColumnWithScrollBar
|
||||
}
|
||||
|
||||
val links = info.nameContact + info.nameChannel
|
||||
val pointsAtMe = myLink != null && links.contains(myLink)
|
||||
|
||||
SectionView(stringResource(MR.strings.names_detail_points_to).uppercase()) {
|
||||
if (links.isEmpty()) {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_detail_no_link), color = WarningOrange) }
|
||||
} else {
|
||||
links.forEach { l ->
|
||||
SectionItemView { Text(l, fontFamily = FontFamily.Monospace, maxLines = 2, color = MaterialTheme.colors.secondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!pointsAtMe && myLink != null) {
|
||||
// Worth surfacing: a name that does not point at this profile does not
|
||||
// bring anyone here, which is the only reason to own it.
|
||||
SectionTextFooter(stringResource(MR.strings.names_detail_mismatch), WarningOrange)
|
||||
SectionView {
|
||||
SectionItemView(click = if (busy) null else repoint, disabled = busy) {
|
||||
Text(
|
||||
if (busy) stringResource(MR.strings.names_repoint_working) else stringResource(MR.strings.names_repoint_action),
|
||||
color = if (busy) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (pointsAtMe) {
|
||||
SectionTextFooter(stringResource(MR.strings.names_detail_points_here), SimplexGreen)
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.names_detail_validity).uppercase()) {
|
||||
SectionItemView {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(stringResource(MR.strings.names_detail_expires))
|
||||
Text(expiryText(info.nameExpires.toLong()), color = expiryColor(info.nameExpires.toLong()))
|
||||
}
|
||||
}
|
||||
SectionItemView {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(stringResource(MR.strings.names_detail_changes_left))
|
||||
Text(info.nameEditCredits.toString(), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_detail_changes_footer))
|
||||
// The app sends no expiry notifications, so the only reminder that will
|
||||
// actually reach the user is one in their own calendar.
|
||||
SectionItemView(click = {
|
||||
val ok = addCalendarReminder(
|
||||
generalGetString(MR.strings.names_remind_title).format(fqdn),
|
||||
generalGetString(MR.strings.names_remind_description),
|
||||
info.nameExpires.toLong(),
|
||||
)
|
||||
if (!ok) showToast(generalGetString(MR.strings.names_remind_none))
|
||||
}) {
|
||||
Text(stringResource(MR.strings.names_remind_action), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
// Nothing renews by itself, so say when it ends rather than implying it continues.
|
||||
SectionTextFooter(stringResource(MR.strings.names_detail_no_autorenew))
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
// Offered whenever the name can still be recovered: while live, through
|
||||
// the grace period, and after it if no one else has taken it - past grace
|
||||
// this buys the name again, which is the same thing to the user.
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_refresh),
|
||||
stringResource(MR.strings.names_renew_action),
|
||||
renew,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = busy,
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_id_card),
|
||||
stringResource(MR.strings.names_give_title),
|
||||
gift,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_give_footer))
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Owning a name and showing it on your profile are two different things: the
|
||||
// wallet owns it, the profile claims it. This is the second step, and it is
|
||||
// what makes the name appear on the SimpleX address screen.
|
||||
fun offerSetPrimaryName(rhId: Long?, fqdn: String, onDone: () -> Unit = {}) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.names_primary_title),
|
||||
text = generalGetString(MR.strings.names_primary_text).format(fqdn),
|
||||
confirmText = generalGetString(MR.strings.names_primary_action),
|
||||
dismissText = generalGetString(MR.strings.names_bought_later),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
try {
|
||||
val u = chatModel.controller.apiSetUserDomain(rhId, fqdn)
|
||||
chatModel.updateUser(u)
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.names_primary_failed), e.message ?: "")
|
||||
}
|
||||
onDone()
|
||||
}
|
||||
},
|
||||
onDismiss = onDone,
|
||||
)
|
||||
}
|
||||
|
||||
// Integer division truncates toward zero, so a name that expired an hour ago
|
||||
// gave 0 and rendered as "Today" in orange - the exact window in which it is
|
||||
// being released to anyone. floorDiv keeps expired negative.
|
||||
private fun expiryDays(expires: Long): Long {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
return Math.floorDiv(expires - now, 86400L)
|
||||
}
|
||||
|
||||
private fun expiryText(expires: Long): String {
|
||||
val d = expiryDays(expires)
|
||||
return when {
|
||||
d < 0 -> generalGetString(MR.strings.names_detail_expired)
|
||||
d == 0L -> generalGetString(MR.strings.names_detail_expires_today)
|
||||
d == 1L -> generalGetString(MR.strings.names_detail_expires_tomorrow)
|
||||
d < 30 -> generalGetString(MR.strings.names_detail_expires_in_days).format(d)
|
||||
else -> Instant.fromEpochSeconds(expires).toString().substring(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expiryColor(expires: Long): Color {
|
||||
val d = expiryDays(expires)
|
||||
return if (d < 0) Color.Red else if (d < 30) WarningOrange else Color.Unspecified
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The recovery key for the user's names.
|
||||
//
|
||||
// For most users this is the only backup they have of anything: database export
|
||||
// and Migrate-to-another-device both refuse to run while the app is on its
|
||||
// initial random passphrase, which is where onboarding leaves everyone. So this
|
||||
// screen is not a wallet formality - it is frequently the first and only backup
|
||||
// the app has ever offered, and it is worded accordingly.
|
||||
//
|
||||
// The stakes are impersonation, not loss. Whoever holds this key can point a
|
||||
// name at their own address, so anyone who knows the name would find them
|
||||
// instead. That is a sharper risk than "you could lose a name" and the copy
|
||||
// says so.
|
||||
@Composable
|
||||
fun NameRecoveryKeyView(rhId: Long?, close: () -> Unit) {
|
||||
val phrase = remember { mutableStateOf<String?>(null) }
|
||||
val loadFailed = remember { mutableStateOf(false) }
|
||||
val saved = remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val r = chatModel.controller.apiNameRecoveryKey(rhId)
|
||||
if (r != null) {
|
||||
phrase.value = r.recoveryPhrase
|
||||
saved.value = r.recoveryKeySaved
|
||||
} else {
|
||||
loadFailed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
NameRecoveryKeyLayout(
|
||||
phrase = phrase.value,
|
||||
loadFailed = loadFailed.value,
|
||||
saved = saved.value,
|
||||
revealed = revealed.value,
|
||||
reveal = { revealed.value = true },
|
||||
markSaved = {
|
||||
scope.launch {
|
||||
if (chatModel.controller.apiNameRecoveryKeySaved(rhId)) {
|
||||
saved.value = true
|
||||
}
|
||||
}
|
||||
},
|
||||
close = close,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameRecoveryKeyLayout(
|
||||
phrase: String?,
|
||||
loadFailed: Boolean,
|
||||
saved: Boolean,
|
||||
revealed: Boolean,
|
||||
reveal: () -> Unit,
|
||||
markSaved: () -> Unit,
|
||||
close: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_recovery_key_title))
|
||||
|
||||
SectionView {
|
||||
SectionItemView {
|
||||
Text(stringResource(MR.strings.names_recovery_key_what), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
|
||||
if (loadFailed) {
|
||||
SectionView {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_list_load_failed), color = WarningOrange) }
|
||||
}
|
||||
} else if (phrase == null) {
|
||||
SectionView {
|
||||
SectionItemView { Text(stringResource(MR.strings.names_recovery_key_none), color = MaterialTheme.colors.secondary) }
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_recovery_key_none_footer))
|
||||
} else if (!revealed) {
|
||||
// Not shown until asked for: this screen can be opened with someone
|
||||
// looking over your shoulder.
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_visibility),
|
||||
stringResource(MR.strings.names_recovery_key_reveal),
|
||||
reveal,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_recovery_key_reveal_footer))
|
||||
} else {
|
||||
SectionView(stringResource(MR.strings.names_recovery_key_section).uppercase()) {
|
||||
SectionItemView(padding = PaddingValues(DEFAULT_PADDING)) {
|
||||
Text(
|
||||
phrase,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
SectionItemView(click = {
|
||||
clipboard.setText(AnnotatedString(phrase))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}) {
|
||||
Text(stringResource(MR.strings.names_recovery_key_copy), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_recovery_key_write_down))
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
if (saved) {
|
||||
SectionItemView {
|
||||
Text(stringResource(MR.strings.names_recovery_key_already_saved), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.names_recovery_key_mark_saved),
|
||||
markSaved,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.names_recovery_key_risk_section).uppercase()) {
|
||||
SectionItemView {
|
||||
Text(stringResource(MR.strings.names_recovery_key_risk), color = Color.Red)
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_recovery_key_passphrase_hint))
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
+6
@@ -96,6 +96,12 @@ fun SettingsLayout(
|
||||
SectionView {
|
||||
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
|
||||
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.your_privacy), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
|
||||
SettingsActionItemWithContent(painterResource(MR.images.ic_alternate_email), stringResource(MR.strings.names_title), click = { ModalManager.start.showModalCloseable { close -> SimplexNamesView(chatModel.remoteHostId(), close) } }, disabled = stopped) {
|
||||
// Names arrive while the user is elsewhere; without this the only
|
||||
// place a waiting name is visible is inside the feature itself.
|
||||
val waiting = chatModel.namesWaiting.value
|
||||
if (waiting > 0) Text(waiting.toString(), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.help_and_support), showSettingsModal { HelpAndSupportView(it, showModal, showCustomModal) })
|
||||
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView() }, stopped)
|
||||
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
// Entry point for everything about this profile's SimpleX names.
|
||||
//
|
||||
// Names bought by this profile, names other people have sent to it, the
|
||||
// meta-address that lets contacts send without a handshake, and the recovery
|
||||
// key. The last one carries a badge until it is acknowledged, because for most
|
||||
// users it is the only backup they have.
|
||||
@Composable
|
||||
fun SimplexNamesView(rhId: Long?, close: () -> Unit) {
|
||||
val names = remember { mutableStateOf<List<OwnedName>?>(null) }
|
||||
val incomingCount = remember { mutableStateOf(0) }
|
||||
val keySaved = remember { mutableStateOf(true) }
|
||||
val hasKey = remember { mutableStateOf(false) }
|
||||
val loadFailed = remember { mutableStateOf(false) }
|
||||
val anySeed = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Status first, and nothing else until it says a wallet exists. Every other
|
||||
// name call goes through userWalletAccount, which creates the seed as a
|
||||
// side effect - so asking for the list first would give a wallet to anyone
|
||||
// who merely opened this screen.
|
||||
val st = chatModel.controller.apiNameStatus(rhId)
|
||||
// A failed read is not the same as "you have nothing": saying so would hide
|
||||
// the unsaved-recovery-key warning from someone who has names.
|
||||
loadFailed.value = st == null
|
||||
hasKey.value = st?.nameHasWallet ?: false
|
||||
keySaved.value = st?.nameKeySaved ?: true
|
||||
anySeed.value = st?.nameAnySeed ?: false
|
||||
if (hasKey.value) {
|
||||
names.value = chatModel.controller.apiNameList(rhId)
|
||||
incomingCount.value = chatModel.controller.apiNameIncoming(rhId)?.size ?: 0
|
||||
chatModel.namesWaiting.value = incomingCount.value
|
||||
} else {
|
||||
names.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// Development overlay: a name claimed on this profile the legacy way, i.e.
|
||||
// registered elsewhere. Shown for reference; wallet actions do not apply.
|
||||
val claimed = chatModel.currentUser.value?.profile?.contactDomain?.domain
|
||||
|
||||
SimplexNamesLayout(
|
||||
names = names.value,
|
||||
claimed = claimed,
|
||||
incomingCount = incomingCount.value,
|
||||
keySaved = keySaved.value,
|
||||
hasKey = hasKey.value,
|
||||
loadFailed = loadFailed.value,
|
||||
hasOtherSeed = anySeed.value,
|
||||
rhId = rhId,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimplexNamesLayout(
|
||||
names: List<OwnedName>?,
|
||||
claimed: String?,
|
||||
incomingCount: Int,
|
||||
keySaved: Boolean,
|
||||
hasKey: Boolean,
|
||||
loadFailed: Boolean,
|
||||
hasOtherSeed: Boolean,
|
||||
rhId: Long?,
|
||||
) {
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_title))
|
||||
|
||||
SectionView(stringResource(MR.strings.names_your_names).uppercase()) {
|
||||
when {
|
||||
loadFailed ->
|
||||
SectionItemView { Text(stringResource(MR.strings.names_list_load_failed), color = WarningOrange) }
|
||||
names == null -> SectionItemView { Text(stringResource(MR.strings.names_incoming_loading), color = MaterialTheme.colors.secondary) }
|
||||
names.isEmpty() -> SectionItemView { Text(stringResource(MR.strings.names_none_yet), color = MaterialTheme.colors.secondary) }
|
||||
else -> names.forEach { n ->
|
||||
val expired = n.onExpires.toLong() - System.currentTimeMillis() / 1000 < 0
|
||||
SectionItemView(click = { ModalManager.start.showModalCloseable { c -> NameDetailView(rhId, n.onFqdn, c) } }) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
// Expired names stay listed - they are still recoverable until
|
||||
// someone else takes them - but read as inactive.
|
||||
Text(n.onFqdn, color = if (expired) MaterialTheme.colors.secondary else Color.Unspecified)
|
||||
if (expired) Text(stringResource(MR.strings.names_list_expired), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_your_names_footer))
|
||||
|
||||
if (claimed != null && names?.any { it.onFqdn == claimed } != true) {
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView(stringResource(MR.strings.names_legacy_section).uppercase()) {
|
||||
SectionItemView(click = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = claimed,
|
||||
text = generalGetString(MR.strings.names_legacy_explain),
|
||||
)
|
||||
}) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(claimed)
|
||||
Text(stringResource(MR.strings.names_legacy_tag), color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(stringResource(MR.strings.names_legacy_footer))
|
||||
}
|
||||
|
||||
SectionDividerSpaced(maxTopPadding = true)
|
||||
SectionView {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(MR.strings.names_buy_title),
|
||||
click = { ModalManager.start.showModalCloseable { close -> BuyNameView(rhId, close) } },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
SettingsActionItemWithContent(
|
||||
painterResource(MR.images.ic_mail),
|
||||
stringResource(MR.strings.names_incoming_title),
|
||||
click = { ModalManager.start.showModalCloseable { close -> IncomingNamesView(rhId, close) } },
|
||||
) {
|
||||
if (incomingCount > 0) {
|
||||
Text(incomingCount.toString(), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
if (!hasKey) SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(MR.strings.names_setup_title),
|
||||
click = { ModalManager.start.showModalCloseable { c -> WalletSetupView(rhId, hasOtherSeed = hasOtherSeed, onDone = c) } },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_download),
|
||||
stringResource(MR.strings.names_import_title),
|
||||
click = { ModalManager.start.showModalCloseable { c -> ImportRecoveryKeyView(rhId, c) } },
|
||||
)
|
||||
if (hasKey) SettingsActionItem(
|
||||
painterResource(MR.images.ic_lock),
|
||||
stringResource(MR.strings.names_recovery_key_title),
|
||||
click = { ModalManager.start.showModalCloseable { close -> NameRecoveryKeyView(rhId, close) } },
|
||||
// Unacknowledged recovery key is the one thing on this screen that can
|
||||
// cost the user everything, so it is coloured until they confirm it.
|
||||
textColor = if (keySaved) Color.Unspecified else WarningOrange,
|
||||
iconColor = if (keySaved) MaterialTheme.colors.secondary else WarningOrange,
|
||||
)
|
||||
}
|
||||
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package chat.simplex.common.views.usersettings
|
||||
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// How this profile gets its recovery key, asked once, the first time it needs
|
||||
// one.
|
||||
//
|
||||
// Sharing the key across profiles is the default because it means one thing to
|
||||
// write down. A separate key exists for a profile kept deliberately apart: its
|
||||
// names are then not derivable from the others. Importing is here rather than
|
||||
// hidden elsewhere because restoring is the same decision, made differently.
|
||||
@Composable
|
||||
fun WalletSetupView(rhId: Long?, hasOtherSeed: Boolean, onDone: () -> Unit) {
|
||||
val busy = remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun setup(arg: String) {
|
||||
scope.launch {
|
||||
busy.value = true
|
||||
try {
|
||||
if (chatModel.controller.apiNameSetup(rhId, arg)) onDone()
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
ColumnWithScrollBar {
|
||||
AppBarTitle(stringResource(MR.strings.names_setup_title))
|
||||
SectionTextFooter(stringResource(MR.strings.names_setup_intro))
|
||||
|
||||
SectionView {
|
||||
if (hasOtherSeed) {
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_check),
|
||||
stringResource(MR.strings.names_setup_existing),
|
||||
{ setup("existing") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = busy.value,
|
||||
)
|
||||
}
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_add),
|
||||
stringResource(if (hasOtherSeed) MR.strings.names_setup_new else MR.strings.names_setup_first),
|
||||
{ setup("new") },
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
disabled = busy.value,
|
||||
)
|
||||
SettingsActionItem(
|
||||
painterResource(MR.images.ic_download),
|
||||
stringResource(MR.strings.names_import_title),
|
||||
{ ModalManager.start.showModalCloseable { c -> ImportRecoveryKeyView(rhId, c) } },
|
||||
disabled = busy.value,
|
||||
)
|
||||
}
|
||||
SectionTextFooter(
|
||||
stringResource(if (hasOtherSeed) MR.strings.names_setup_footer_shared else MR.strings.names_setup_footer_first)
|
||||
)
|
||||
SectionBottomSpacer()
|
||||
}
|
||||
}
|
||||
@@ -3198,4 +3198,161 @@
|
||||
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
|
||||
<string name="badge_unknown_key_title">Badge cannot be verified</string>
|
||||
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
|
||||
<string name="names_incoming_title">Names sent to you</string>
|
||||
<string name="names_incoming_section">Waiting for your decision</string>
|
||||
<string name="names_incoming_none">No names have been sent to you</string>
|
||||
<string name="names_incoming_loading">Checking…</string>
|
||||
<string name="names_incoming_footer">Accepting adds the name to your names. Declining removes it from this list and tells no one.</string>
|
||||
<string name="names_incoming_accept">Accept</string>
|
||||
<string name="names_incoming_decline">Decline</string>
|
||||
<string name="names_accept_title">Accept this name?</string>
|
||||
<string name="names_accept_text">%s will be added to your names. You can choose to show it on your profile afterwards.</string>
|
||||
<string name="names_accept_failed">Could not accept the name</string>
|
||||
<string name="names_decline_title">Decline this name?</string>
|
||||
<string name="names_decline_text">No one is told. The name is already yours and stays where it is, but it will not be shown here again and cannot be brought back from the app. Only decline if you do not want it.</string>
|
||||
<string name="names_decline_failed">Could not decline the name</string>
|
||||
<string name="names_gift_failed">Could not give the name away</string>
|
||||
<string name="names_rescan">Check for names sent to you</string>
|
||||
<string name="names_rescan_footer">Names are normally delivered by message. Check here if you restored this device from your recovery key, or think you missed one.</string>
|
||||
<string name="names_rescan_done_title">Check complete</string>
|
||||
<string name="names_rescan_none">No new names found.</string>
|
||||
<string name="names_rescan_found">Found %d names sent to you — see the list above.</string>
|
||||
<string name="names_rescan_found_one">Found a name sent to you — see the list above.</string>
|
||||
<string name="names_title">SimpleX names</string>
|
||||
<string name="names_your_names">Your names</string>
|
||||
<string name="names_none_yet">You do not have a name yet</string>
|
||||
<string name="names_your_names_footer">A SimpleX name lets people find you without sharing a link. If you save your recovery key, you can point the name at a new profile after losing this device.</string>
|
||||
<string name="names_recovery_key_title">Recovery key</string>
|
||||
<string name="names_recovery_key_section">Your recovery key</string>
|
||||
<string name="names_recovery_key_what">These words control your names. Anyone who has them controls your names too.</string>
|
||||
<string name="names_recovery_key_none">You will get a recovery key when you get your first name.</string>
|
||||
<string name="names_recovery_key_none_footer">Nothing to back up yet.</string>
|
||||
<string name="names_recovery_key_reveal">Show recovery key</string>
|
||||
<string name="names_recovery_key_reveal_footer">Make sure no one can see your screen.</string>
|
||||
<string name="names_recovery_key_copy">Copy</string>
|
||||
<string name="names_recovery_key_write_down">Write these words down and keep them somewhere safe and offline. Anyone who takes a photo of this screen can take your names.</string>
|
||||
<string name="names_recovery_key_mark_saved">I have saved it</string>
|
||||
<string name="names_recovery_key_already_saved">You marked this as saved.</string>
|
||||
<string name="names_recovery_key_risk_section">If someone else gets this key</string>
|
||||
<string name="names_recovery_key_risk">They can point your name at their own profile, so people looking for you would find them instead.</string>
|
||||
<string name="names_recovery_key_passphrase_hint">This is separate from your database passphrase. If you have not set a database passphrase, this recovery key is your only backup — nothing else on this device can be restored.</string>
|
||||
<string name="names_buy_title">Buy a name</string>
|
||||
<string name="names_buy_choose">Choose a name</string>
|
||||
<string name="names_buy_placeholder">yourname</string>
|
||||
<string name="names_buy_rules">Letters, digits and hyphens. At least 6 characters.</string>
|
||||
<string name="names_buy_term">How long</string>
|
||||
<string name="names_buy_years">%d years</string>
|
||||
<string name="names_buy_no_subscription">You pay once for the whole period. Nothing renews automatically, and the app will not remind you — note the end date.</string>
|
||||
<string name="names_buy_too_short">A bit short — names need at least 6 characters.</string>
|
||||
<string name="names_buy_checking">Checking…</string>
|
||||
<string name="names_buy_unknown">Could not check that name — it may be reserved, or the service is unreachable.</string>
|
||||
<string name="names_buy_taken">That name is already taken.</string>
|
||||
<string name="names_buy_action">Buy this name — %s</string>
|
||||
<string name="names_buy_working">Registering…</string>
|
||||
<string name="names_buy_failed">Could not buy the name</string>
|
||||
<string name="names_bought_title">The name is yours</string>
|
||||
<string name="names_bought_text">%s now points at this profile.</string>
|
||||
<string name="names_bought_save_key">Save recovery key</string>
|
||||
<string name="names_bought_later">Later</string>
|
||||
<string name="names_buy_dev_section">Development build</string>
|
||||
<string name="names_buy_dev_warning">Test build: no payment is taken and nothing is registered. Names disappear when the app closes.</string>
|
||||
<string name="names_receive_off">No one can send you a name yet.</string>
|
||||
<string name="names_receive_enable">Let people send me names</string>
|
||||
<string name="names_receive_off_footer">Turn this on so your contacts can send you names. Your profile starts carrying a marker that lets them send you a name and nothing else.</string>
|
||||
<string name="names_receive_enabled_title">You can now be sent names</string>
|
||||
<string name="names_receive_enabled_text">Your contacts can now send you names. You also have a recovery key — it is the only way to reach anything sent to you if you lose this device.</string>
|
||||
<string name="names_buy_available_a11y">Available</string>
|
||||
<string name="names_buy_bad_chars">Use letters, digits and single hyphens between them.</string>
|
||||
<string name="names_buy_available">Available — %s</string>
|
||||
<string name="names_buy_check_a11y">Check the name</string>
|
||||
<string name="names_incoming_ready_footer">Your profile already carries everything your contacts need to send you a name — there is nothing to set up. Anything sent to you appears here, and nothing is added to your profile until you accept it.</string>
|
||||
<string name="names_detail_points_to">Points to</string>
|
||||
<string name="names_detail_no_link">This name does not point anywhere yet.</string>
|
||||
<string name="names_detail_mismatch">This name does not point at this profile, so it will not bring anyone to you.</string>
|
||||
<string name="names_detail_points_here">This name points at this profile.</string>
|
||||
<string name="names_detail_validity">How long you have it</string>
|
||||
<string name="names_detail_expires">Expires</string>
|
||||
<string name="names_detail_expired">Expired</string>
|
||||
<string name="names_detail_expires_today">Today</string>
|
||||
<string name="names_detail_changes_left">Changes left</string>
|
||||
<string name="names_detail_no_autorenew">Nothing renews automatically. If it expires you still have 90 days to extend it; after that anyone can take it.</string>
|
||||
<string name="names_repoint_title">Point at this profile?</string>
|
||||
<string name="names_repoint_text">%s will point at this profile, so people using the name reach you. Your address becomes part of a public record, and this uses one of your remaining changes.</string>
|
||||
<string name="names_repoint_action">Point at this profile</string>
|
||||
<string name="names_repoint_working">Updating…</string>
|
||||
<string name="names_repoint_failed">Could not update the name</string>
|
||||
<string name="names_give_title">Give this name away</string>
|
||||
<string name="names_give_intro">%s will become theirs. You will not be able to take it back.</string>
|
||||
<string name="names_give_choose">Give to</string>
|
||||
<string name="names_give_nobody">None of your contacts can receive a name yet.</string>
|
||||
<string name="names_give_footer">Only contacts who can receive names are listed.</string>
|
||||
<string name="names_give_some_cannot">%d of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on.</string>
|
||||
<string name="names_give_one_cannot">One of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on.</string>
|
||||
<string name="names_give_confirm_title">Give the name away?</string>
|
||||
<string name="names_give_confirm_text">%1$s will belong to %2$s. Only they will be able to change or move it after this.</string>
|
||||
<string name="names_give_action">Give it away</string>
|
||||
<string name="names_give_sent_title">Name given away</string>
|
||||
<string name="names_give_sent_text">%1$s is now %2$s\'s. They will see it in SimpleX names.</string>
|
||||
<string name="names_buy_confirm_title">Point this name at your profile?</string>
|
||||
<string name="names_buy_confirm_text">%s will point at this profile\'s address, so anyone using the name reaches you. Your address becomes part of a public record.</string>
|
||||
<string name="names_buy_confirm_action">Buy it</string>
|
||||
<string name="names_primary_title">Use this as your SimpleX name?</string>
|
||||
<string name="names_primary_text">%s will be shown on your profile, and people can find you by it instead of a link.</string>
|
||||
<string name="names_primary_action">Use it</string>
|
||||
<string name="names_primary_failed">Could not set your SimpleX name</string>
|
||||
<string name="names_buy_point_at_me">Point this name at my profile</string>
|
||||
<string name="names_buy_point_at_me_on">People using the name will reach this profile.</string>
|
||||
<string name="names_buy_point_at_me_off">The name will point nowhere. Buy it this way if it is for someone else — you can give it to them afterwards.</string>
|
||||
<string name="names_buy_confirm_other_title">Buy this name?</string>
|
||||
<string name="names_buy_confirm_other_text">%s will be registered without pointing anywhere. You can give it to someone from your names list.</string>
|
||||
<string name="names_bought_for_other">%s is registered and points nowhere yet. Give it to someone from your names list.</string>
|
||||
<string name="names_legacy_section">Shown on your profile</string>
|
||||
<string name="names_legacy_tag">not managed here</string>
|
||||
<string name="names_legacy_footer">This name is shown on your profile, but this app does not manage it.</string>
|
||||
<string name="names_legacy_explain">This name is shown on your profile, but this app does not manage it — it cannot be given away or re-pointed from here.</string>
|
||||
<string name="names_buy_no_address">You do not have a SimpleX address yet, so there is nothing for the name to point at. Create one first, or buy the name to give away.</string>
|
||||
<string name="names_transfer_sent">You sent the SimpleX name %s</string>
|
||||
<string name="names_transfer_received">You were sent the SimpleX name %s — open SimpleX names to accept it</string>
|
||||
<string name="names_transfer_unknown">You were sent a SimpleX name. Update SimpleX to accept it.</string>
|
||||
<string name="names_import_title">Restore names from a recovery key</string>
|
||||
<string name="names_import_intro">If you have a recovery key from another device, enter it here to get your names back. Nothing is bought and no one is contacted.</string>
|
||||
<string name="names_import_section">Recovery key</string>
|
||||
<string name="names_import_placeholder">your recovery words, separated by spaces</string>
|
||||
<string name="names_import_hint">Enter the words in the order they were shown to you.</string>
|
||||
<string name="names_import_word_count">%d words entered — a recovery key has 12, 15, 18, 21 or 24.</string>
|
||||
<string name="names_import_word_count_one">1 word entered — a recovery key has 12, 15, 18, 21 or 24.</string>
|
||||
<string name="names_import_action">Restore</string>
|
||||
<string name="names_import_working">Restoring…</string>
|
||||
<string name="names_import_failed">That recovery key was not accepted</string>
|
||||
<string name="names_import_done_title">Restored</string>
|
||||
<string name="names_import_done_none">Recovery key added. Open Your names to see what it holds.</string>
|
||||
<string name="names_import_done_found">Recovery key added, and %d names were sent to you. Check Your names and names sent to you.</string>
|
||||
<string name="names_detail_expires_in_days">In %d days</string>
|
||||
<string name="names_detail_expires_tomorrow">Tomorrow</string>
|
||||
<string name="names_detail_changes_footer">Pointing this name somewhere new uses one change. Extending the name adds 10 more.</string>
|
||||
<string name="names_renew_action">Extend this name</string>
|
||||
<string name="names_renew_title">Extend this name?</string>
|
||||
<string name="names_renew_text">%s will be yours for another year.</string>
|
||||
<string name="names_renew_done_title">Extended</string>
|
||||
<string name="names_renew_done">%s is yours for another year.</string>
|
||||
<string name="names_renew_done_again">%s had expired, so it was registered again. It is yours for another year.</string>
|
||||
<string name="names_renew_failed">Could not extend the name</string>
|
||||
<string name="names_expired_note">This name has expired. You have 90 days to extend it and keep it; after that anyone can take it.</string>
|
||||
<string name="names_list_expired">expired</string>
|
||||
<string name="names_remind_action">Add expiry to my calendar</string>
|
||||
<string name="names_remind_title">SimpleX name %s expires</string>
|
||||
<string name="names_remind_description">Extend it in SimpleX to keep it. You have 90 days after it expires; after that anyone can take it.</string>
|
||||
<string name="names_remind_none">No calendar app to add a reminder to.</string>
|
||||
<string name="names_buy_year_one">1 year</string>
|
||||
<string name="names_list_load_failed">Could not load your names. Check your connection and reopen this screen.</string>
|
||||
<string name="names_bought_not_shown">%s is yours, but it is not shown on your profile yet — you need a SimpleX address first.</string>
|
||||
<string name="names_setup_title">Set up names for this profile</string>
|
||||
<string name="names_setup_intro">Names are kept safe by a recovery key. Choose how this profile gets one.</string>
|
||||
<string name="names_setup_existing">Use the recovery key I already have</string>
|
||||
<string name="names_setup_new">Give this profile its own recovery key</string>
|
||||
<string name="names_setup_first">Create a recovery key</string>
|
||||
<string name="names_setup_footer_shared">Using the key you already have means one set of words to write down, and it covers every profile. A separate key keeps this profile\'s names unconnected to your others, but is a second thing to keep safe.</string>
|
||||
<string name="names_setup_footer_first">You will be shown the key afterwards. Write it down — it is the only way to get your names back if you lose this device.</string>
|
||||
<string name="names_setup_failed">Could not set up names for this profile</string>
|
||||
<string name="names_import_already">This profile already has a recovery key, so you cannot import a different one here. To use another key, set up a new profile and import it there.</string>
|
||||
</resources>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
// Desktop has no single calendar entry point worth guessing at; the date is
|
||||
// shown on screen and can be noted by hand.
|
||||
actual fun addCalendarReminder(title: String, description: String, epochSeconds: Long, remindDaysBefore: Int): Boolean = false
|
||||
Reference in new issue
Block a user