From 31cd2619da85caee58ba0689f91c8c0f0965b041 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 17 Aug 2026 10:24:27 +0200 Subject: [PATCH] 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. --- apps/ios/Shared/Model/AppAPITypes.swift | 79 +++++ apps/ios/Shared/Model/ChatModel.swift | 3 + apps/ios/Shared/Model/SimpleXAPI.swift | 134 +++++++- .../Views/Chat/ChatItem/FramedItemView.swift | 28 ++ .../Views/UserSettings/SettingsView.swift | 16 + .../SimplexNames/BuyNameView.swift | 129 ++++++++ .../SimplexNames/GiveNameView.swift | 53 +++ .../SimplexNames/ImportRecoveryKeyView.swift | 50 +++ .../SimplexNames/IncomingNamesView.swift | 112 +++++++ .../SimplexNames/NameDetailView.swift | 98 ++++++ .../SimplexNames/NamePayment.swift | 26 ++ .../SimplexNames/NameRecoveryKeyView.swift | 49 +++ .../SimplexNames/SimplexNamesView.swift | 96 ++++++ .../SimplexNames/WalletSetupView.swift | 34 ++ apps/ios/SimpleXChat/ChatTypes.swift | 45 ++- .../common/platform/Calendar.android.kt | 30 ++ .../chat/simplex/common/model/ChatModel.kt | 61 +++- .../chat/simplex/common/model/NamePayment.kt | 64 ++++ .../chat/simplex/common/model/SimpleXAPI.kt | 266 ++++++++++++++++ .../chat/simplex/common/platform/Calendar.kt | 18 ++ .../simplex/common/views/chat/ComposeView.kt | 3 + .../common/views/chat/item/FramedItemView.kt | 21 ++ .../common/views/chat/item/TextItemView.kt | 13 + .../common/views/usersettings/BuyNameView.kt | 301 ++++++++++++++++++ .../common/views/usersettings/GiveNameView.kt | 92 ++++++ .../usersettings/ImportRecoveryKeyView.kt | 112 +++++++ .../views/usersettings/IncomingNamesView.kt | 204 ++++++++++++ .../views/usersettings/NameDetailView.kt | 265 +++++++++++++++ .../views/usersettings/NameRecoveryKeyView.kt | 163 ++++++++++ .../common/views/usersettings/SettingsView.kt | 6 + .../views/usersettings/SimplexNamesView.kt | 169 ++++++++++ .../views/usersettings/WalletSetupView.kt | 75 +++++ .../commonMain/resources/MR/base/strings.xml | 157 +++++++++ .../common/platform/Calendar.desktop.kt | 5 + 34 files changed, 2970 insertions(+), 7 deletions(-) create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/BuyNameView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/GiveNameView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/ImportRecoveryKeyView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/IncomingNamesView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/NameDetailView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/NamePayment.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/NameRecoveryKeyView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/SimplexNamesView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/SimplexNames/WalletSetupView.swift create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Calendar.android.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/NamePayment.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Calendar.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/BuyNameView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/GiveNameView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ImportRecoveryKeyView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncomingNamesView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameDetailView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameRecoveryKeyView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SimplexNamesView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/WalletSetupView.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Calendar.desktop.kt diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 40b88ec338..9b3a5b2511 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -158,6 +158,22 @@ enum ChatCommand: ChatCmdProtocol { case apiSetProfileAddress(userId: Int64, on: Bool) case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings) case apiSetUserDomain(userId: Int64, simplexDomain: String?) + case apiNameStatus(userId: Int64) + case apiNameSetup(userId: Int64, setup: String) + case apiNameAddress(userId: Int64) + case apiNameList(userId: Int64) + case apiNameQuote(userId: Int64, label: String) + case apiNameBuy(userId: Int64, label: String, years: Int, payment: String, link: String?) + case apiNameInfo(userId: Int64, fqdn: String) + case apiNameSetLink(userId: Int64, fqdn: String, link: String) + case apiNameRenew(userId: Int64, fqdn: String, years: Int, payment: String) + case apiNameGift(userId: Int64, label: String, recipient: String) + case apiNameIncoming(userId: Int64) + case apiNameAccept(userId: Int64, address: String) + case apiNameDecline(userId: Int64, address: String) + case apiNameRescan(userId: Int64) + case apiNameRecoveryKey(userId: Int64) + case apiNameRecoveryKeySaved(userId: Int64) case apiVerifyContactDomain(contactId: Int64) case apiVerifyGroupDomain(groupId: Int64) case apiAcceptContact(incognito: Bool, contactReqId: Int64) @@ -380,6 +396,22 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))" case let .apiSetUserDomain(userId, simplexDomain): return "/_set domain \(userId)" + (simplexDomain.map { " " + $0 } ?? "") + case let .apiNameStatus(userId): return "/_name status \(userId)" + case let .apiNameSetup(userId, setup): return "/_name setup \(userId) \(setup)" + case let .apiNameAddress(userId): return "/_name address \(userId)" + case let .apiNameList(userId): return "/_name list \(userId)" + case let .apiNameQuote(userId, label): return "/_name quote \(userId) \(label)" + case let .apiNameBuy(userId, label, years, payment, link): return "/_name buy \(userId) \(label) \(years) \(payment)" + (link.map { " " + $0 } ?? "") + case let .apiNameInfo(userId, fqdn): return "/_name info \(userId) \(fqdn)" + case let .apiNameSetLink(userId, fqdn, link): return "/_name link \(userId) \(fqdn) \(link)" + case let .apiNameRenew(userId, fqdn, years, payment): return "/_name renew \(userId) \(fqdn) \(years) \(payment)" + case let .apiNameGift(userId, label, recipient): return "/_name gift \(userId) \(label) \(recipient)" + case let .apiNameIncoming(userId): return "/_name incoming \(userId)" + case let .apiNameAccept(userId, address): return "/_name accept \(userId) \(address)" + case let .apiNameDecline(userId, address): return "/_name decline \(userId) \(address)" + case let .apiNameRescan(userId): return "/_name rescan \(userId)" + case let .apiNameRecoveryKey(userId): return "/_name key \(userId)" + case let .apiNameRecoveryKeySaved(userId): return "/_name key saved \(userId)" case let .apiSetPublicGroupAccess(groupId, access): return "/_public group access #\(groupId) \(encodeJSON(access))" case let .apiVerifyContactDomain(contactId): return "/_verify domain @\(contactId)" case let .apiVerifyGroupDomain(groupId): return "/_verify domain #\(groupId)" @@ -567,6 +599,22 @@ enum ChatCommand: ChatCmdProtocol { case .apiSetProfileAddress: return "apiSetProfileAddress" case .apiSetAddressSettings: return "apiSetAddressSettings" case .apiSetUserDomain: return "apiSetUserDomain" + case .apiNameStatus: return "apiNameStatus" + case .apiNameSetup: return "apiNameSetup" + case .apiNameAddress: return "apiNameAddress" + case .apiNameList: return "apiNameList" + case .apiNameQuote: return "apiNameQuote" + case .apiNameBuy: return "apiNameBuy" + case .apiNameInfo: return "apiNameInfo" + case .apiNameSetLink: return "apiNameSetLink" + case .apiNameRenew: return "apiNameRenew" + case .apiNameGift: return "apiNameGift" + case .apiNameIncoming: return "apiNameIncoming" + case .apiNameAccept: return "apiNameAccept" + case .apiNameDecline: return "apiNameDecline" + case .apiNameRescan: return "apiNameRescan" + case .apiNameRecoveryKey: return "apiNameRecoveryKey" + case .apiNameRecoveryKeySaved: return "apiNameRecoveryKeySaved" case .apiVerifyContactDomain: return "apiVerifyContactDomain" case .apiVerifyGroupDomain: return "apiVerifyGroupDomain" case .apiAcceptContact: return "apiAcceptContact" @@ -1015,6 +1063,22 @@ enum ChatResponse2: Decodable, ChatAPIResult { case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) + // SimpleX names + case nameStatus(user: User, nameHasWallet: Bool, nameKeySaved: Bool, nameAnySeed: Bool) + case nameAddress(user: User, nameAddress: String, nameAccount: Int, nameMetaAddress: String) + case namesOwned(user: User, ownedNames: [OwnedName]) + case nameQuoted(user: User, nameLabel: String, nameAvailable: Bool, namePriceCents: Int) + case nameRegistered(user: User, nameFqdn: String, nameTxHash: String) + case nameInfo(user: User, nameFqdn: String, nameOwner: String, nameContact: [String], nameChannel: [String], nameExpires: Int, nameEditCredits: Int) + case nameIntentRelayed(user: User, nameAction: String, nameFqdn: String, nameTxHash: String) + case nameGifted(user: User, nameFqdn: String, nameTxHash: String, nameEphemeralPubKey: String) + case nameRenewed(user: User, nameFqdn: String, nameExpires: Int, nameReRegistered: Bool) + case namesIncoming(user: User, incomingNames: [IncomingName]) + case nameAccepted(user: User, nameOneTimeAddr: String, acceptedNames: [String]) + case nameDeclined(user: User, nameOneTimeAddr: String) + case nameKeyExported(user: User, nameOneTimeAddr: String, nameOneTimeKey: String) + case nameRescanned(user: User, rescanFound: Int) + case nameRecoveryKey(user: User, recoveryPhrase: String, recoveryKeySaved: Bool) var responseType: String { switch self { @@ -1066,6 +1130,21 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveExported: "archiveExported" case .archiveImported: "archiveImported" case .appSettings: "appSettings" + case .nameStatus: "nameStatus" + case .nameAddress: "nameAddress" + case .namesOwned: "namesOwned" + case .nameQuoted: "nameQuoted" + case .nameRegistered: "nameRegistered" + case .nameInfo: "nameInfo" + case .nameIntentRelayed: "nameIntentRelayed" + case .nameGifted: "nameGifted" + case .nameRenewed: "nameRenewed" + case .namesIncoming: "namesIncoming" + case .nameAccepted: "nameAccepted" + case .nameDeclined: "nameDeclined" + case .nameKeyExported: "nameKeyExported" + case .nameRescanned: "nameRescanned" + case .nameRecoveryKey: "nameRecoveryKey" } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..cce5903fb1 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -363,6 +363,9 @@ class ChannelRelaysModel: ObservableObject { final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false + // Names sent to this profile and not yet accepted or declined. + @Published var namesWaiting = 0 + @Published var showingNamesFromChat = false @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? { didSet { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7a934fc746..9b82c81943 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1379,6 +1379,132 @@ func showSetSimplexNameError(_ r: APIResult, isChannel: Bool) async { } } +// MARK: - SimpleX names + +func apiNameStatus() async -> ChatResponse2? { + guard let userId = try? currentUserId("apiNameStatus") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameStatus(userId: userId)) + if case let .result(res) = r, case .nameStatus = res { return res } + logger.error("apiNameStatus error: \(responseError(r.unexpected))") + return nil +} + +func apiNameSetup(_ setup: String) async -> Bool { + guard let userId = try? currentUserId("apiNameSetup") else { return false } + let r: APIResult = await chatApiSendCmd(.apiNameSetup(userId: userId, setup: setup)) + if case let .result(res) = r, case .nameStatus = res { return true } + await MainActor.run { showAlert(NSLocalizedString("Could not set up names for this profile", comment: "alert title"), message: responseError(r.unexpected)) } + return false +} + +func apiNameAddress() async -> String? { + guard let userId = try? currentUserId("apiNameAddress") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameAddress(userId: userId)) + if case let .result(.nameAddress(_, addr, _, _)) = r { return addr } + logger.error("apiNameAddress error: \(responseError(r.unexpected))") + return nil +} + +func apiNameList() async -> [OwnedName]? { + guard let userId = try? currentUserId("apiNameList") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameList(userId: userId)) + if case let .result(.namesOwned(_, names)) = r { return names } + logger.error("apiNameList error: \(responseError(r.unexpected))") + return nil +} + +func apiNameQuote(_ label: String) async -> (available: Bool, priceCents: Int)? { + guard let userId = try? currentUserId("apiNameQuote") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameQuote(userId: userId, label: label)) + if case let .result(.nameQuoted(_, _, available, price)) = r { return (available, price) } + logger.error("apiNameQuote error: \(responseError(r.unexpected))") + return nil +} + +func apiNameBuy(_ label: String, years: Int, payment: String, link: String?) async -> String? { + guard let userId = try? currentUserId("apiNameBuy") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameBuy(userId: userId, label: label, years: years, payment: payment, link: link)) + if case let .result(.nameRegistered(_, fqdn, _)) = r { return fqdn } + await MainActor.run { showAlert(NSLocalizedString("Could not buy the name", comment: "alert title"), message: responseError(r.unexpected)) } + return nil +} + +func apiNameInfo(_ fqdn: String) async -> ChatResponse2? { + guard let userId = try? currentUserId("apiNameInfo") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameInfo(userId: userId, fqdn: fqdn)) + if case let .result(res) = r, case .nameInfo = res { return res } + logger.error("apiNameInfo error: \(responseError(r.unexpected))") + return nil +} + +func apiNameSetLink(_ fqdn: String, link: String) async -> Bool { + guard let userId = try? currentUserId("apiNameSetLink") else { return false } + let r: APIResult = await chatApiSendCmd(.apiNameSetLink(userId: userId, fqdn: fqdn, link: link)) + if case .result(.nameIntentRelayed) = r { return true } + await MainActor.run { showAlert(NSLocalizedString("Could not update the name", comment: "alert title"), message: responseError(r.unexpected)) } + return false +} + +func apiNameRenew(_ fqdn: String, years: Int, payment: String) async -> (expires: Int, reRegistered: Bool)? { + guard let userId = try? currentUserId("apiNameRenew") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameRenew(userId: userId, fqdn: fqdn, years: years, payment: payment)) + if case let .result(.nameRenewed(_, _, expires, reReg)) = r { return (expires, reReg) } + await MainActor.run { showAlert(NSLocalizedString("Could not extend the name", comment: "alert title"), message: responseError(r.unexpected)) } + return nil +} + +func apiNameGift(_ label: String, recipient: String) async -> Bool { + guard let userId = try? currentUserId("apiNameGift") else { return false } + let r: APIResult = await chatApiSendCmd(.apiNameGift(userId: userId, label: label, recipient: recipient)) + if case .result(.nameGifted) = r { return true } + await MainActor.run { showAlert(NSLocalizedString("Could not give the name away", comment: "alert title"), message: responseError(r.unexpected)) } + return false +} + +func apiNameIncoming() async -> [IncomingName]? { + guard let userId = try? currentUserId("apiNameIncoming") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameIncoming(userId: userId)) + if case let .result(.namesIncoming(_, names)) = r { return names } + logger.error("apiNameIncoming error: \(responseError(r.unexpected))") + return nil +} + +func apiNameAccept(_ address: String) async -> [String]? { + guard let userId = try? currentUserId("apiNameAccept") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameAccept(userId: userId, address: address)) + if case let .result(.nameAccepted(_, _, names)) = r { return names } + await MainActor.run { showAlert(NSLocalizedString("Could not accept the name", comment: "alert title"), message: responseError(r.unexpected)) } + return nil +} + +func apiNameDecline(_ address: String) async -> Bool { + guard let userId = try? currentUserId("apiNameDecline") else { return false } + let r: APIResult = await chatApiSendCmd(.apiNameDecline(userId: userId, address: address)) + if case .result(.nameDeclined) = r { return true } + return false +} + +func apiNameRescan() async -> Int? { + guard let userId = try? currentUserId("apiNameRescan") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameRescan(userId: userId)) + if case let .result(.nameRescanned(_, found)) = r { return found } + return nil +} + +func apiNameRecoveryKey() async -> (phrase: String, saved: Bool)? { + guard let userId = try? currentUserId("apiNameRecoveryKey") else { return nil } + let r: APIResult = await chatApiSendCmd(.apiNameRecoveryKey(userId: userId)) + if case let .result(.nameRecoveryKey(_, phrase, saved)) = r { return (phrase, saved) } + return nil +} + +func apiNameRecoveryKeySaved() async -> Bool { + guard let userId = try? currentUserId("apiNameRecoveryKeySaved") else { return false } + let r: APIResult = await chatApiSendCmd(.apiNameRecoveryKeySaved(userId: userId)) + if case .result(.nameRecoveryKey) = r { return true } + return false +} + func apiSetUserDomain(_ simplexDomain: String?) async throws -> User { let userId = try currentUserId("apiSetUserDomain") let r: APIResult = await chatApiSendCmd(.apiSetUserDomain(userId: userId, simplexDomain: simplexDomain)) @@ -2531,7 +2657,13 @@ func processReceivedMsg(_ res: ChatEvent) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { + } + // A name arriving is the one incoming event with no other + // signal, so the settings badge is bumped on arrival. + if case .sndReceived = cItem.meta.itemStatus {} else if case .rcvNew = cItem.meta.itemStatus, case .assetTransfer = cItem.content.msgContent { + m.namesWaiting += 1 + } + if !active(user) && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 44284350dc..19b63a635e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -169,6 +169,18 @@ struct FramedItemView: View { case let .link(_, preview): CILinkView(linkPreview: preview, maxWidth: maxWidth) ciMsgContentView(chatItem) + case let .assetTransfer(_, transfer): + // Rendered from the payload, in the reader's language; tapping + // opens SimpleX names. The sender's text is only a fallback. + Text(assetTransferText(transfer, sent: chatItem.chatDir.sent)) + .foregroundColor(chatItem.chatDir.sent ? theme.colors.onBackground : theme.colors.primary) + .padding(.horizontal, 12).padding(.vertical, 6) + .overlay(DetermineWidth()) + .simultaneousGesture(TapGesture().onEnded { + if !chatItem.chatDir.sent { + openSimplexNames() + } + }) case let .chat(text, chatLink, ownerSig): let hasText = text != chatLink.connLinkStr CIChatLinkHeader(chatItem: chatItem, chatLink: chatLink, ownerSig: ownerSig, hasText: hasText) @@ -464,3 +476,19 @@ struct FramedItemView_Deleted_Previews: PreviewProvider { .previewLayout(.fixed(width: 360, height: 200)) } } + +func assetTransferText(_ t: AssetTransfer, sent: Bool) -> String { + switch t.kind { + case AssetTransfer.kindSimplexName: + return sent + ? String.localizedStringWithFormat(NSLocalizedString("You sent the SimpleX name %@", comment: "chat item"), t.asset) + : String.localizedStringWithFormat(NSLocalizedString("You were sent the SimpleX name %@ — open SimpleX names to accept it", comment: "chat item"), t.asset) + default: + return NSLocalizedString("You were sent a SimpleX name. Update SimpleX to accept it.", comment: "chat item") + } +} + +// Best effort: present the names screen from the current top controller. +@MainActor func openSimplexNames() { + ChatModel.shared.showingNamesFromChat = true +} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4bd5f7db1b..74fda252ac 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -318,6 +318,22 @@ struct SettingsView: View { } .disabled(chatModel.chatRunning != true) + NavigationLink { + SimplexNamesView() + .modifier(ThemedBackground(grouped: true)) + } label: { + settingsRow("at", color: theme.colors.secondary) { + HStack { + Text("SimpleX names") + if chatModel.namesWaiting > 0 { + Spacer() + Text("\(chatModel.namesWaiting)").foregroundColor(theme.colors.primary) + } + } + } + } + .disabled(chatModel.chatRunning != true) + NavigationLink { helpAndSupportView } label: { diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/BuyNameView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/BuyNameView.swift new file mode 100644 index 0000000000..6898f0aebd --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/BuyNameView.swift @@ -0,0 +1,129 @@ +import SwiftUI +import SimpleXChat + +// Buying a name: choose it, see availability and price, pay. Payment goes +// through NamePayment, the only place that knows about the store. +struct BuyNameView: View { + @Environment(\.dismiss) var dismiss + @State private var label = "" + @State private var years = 1 + @State private var quote: (available: Bool, priceCents: Int)? = nil + @State private var checking = false + @State private var buying = false + @State private var pointAtMe = true + + private var myLink: String? { + ChatModel.shared.userAddress?.connLinkContact.simplexChatUri + } + private var validLabel: Bool { + let l = label.trimmingCharacters(in: .whitespaces).lowercased() + return l.count >= 6 && l.range(of: "^[a-z0-9]+(-[a-z0-9]+)*$", options: .regularExpression) != nil + } + + var body: some View { + List { + Section("Choose a name") { + HStack { + TextField("yourname", text: $label) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Text(".simplex").foregroundColor(.secondary) + statusIcon + } + } footer: { statusFooter } + + Section("How long") { + Stepper(value: $years, in: 1...10) { + Text(years == 1 ? "1 year" : "\(years) years") + } + } footer: { + Text("You pay once for the whole period. Nothing renews automatically, and the app will not remind you — note the end date.") + } + + Section { + Toggle("Point this name at my profile", isOn: $pointAtMe) + .disabled(myLink == nil) + } footer: { + if myLink == nil { + Text("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.").foregroundColor(.orange) + } else if pointAtMe { + Text("People using the name will reach this profile.") + } else { + Text("The name will point nowhere. Buy it this way if it is for someone else — you can give it to them afterwards.") + } + } + + if let q = quote, q.available { + Section { + Button { + buy() + } label: { + Text(buying ? "Registering…" : "Buy this name — \(priceText(q.priceCents))") + } + .disabled(buying) + } + } + + if !NamePayment.isLive { + Section("Development build") { + Text("Test build: no payment is taken and nothing is registered. Names disappear when the app closes.") + .foregroundColor(.orange) + } + } + } + .navigationTitle("Buy a name") + .onChange(of: label) { _ in Task { await requote() } } + .onAppear { if myLink == nil { pointAtMe = false } } + } + + @ViewBuilder private var statusIcon: some View { + if !validLabel { Image(systemName: "exclamationmark.circle").foregroundColor(.orange) } + else if checking { ProgressView() } + else if let q = quote { Image(systemName: q.available ? "checkmark.circle" : "xmark.circle").foregroundColor(q.available ? .green : .red) } + else { Image(systemName: "exclamationmark.circle").foregroundColor(.orange) } + } + + @ViewBuilder private var statusFooter: some View { + let l = label.trimmingCharacters(in: .whitespaces).lowercased() + if l.isEmpty { Text("Letters, digits and hyphens. At least 6 characters.") } + else if l.range(of: "^[a-z0-9]+(-[a-z0-9]+)*$", options: .regularExpression) == nil { Text("Use letters, digits and single hyphens between them.").foregroundColor(.orange) } + else if l.count < 6 { Text("A bit short — names need at least 6 characters.").foregroundColor(.orange) } + else if checking { Text("Checking…") } + else if quote == nil { Text("Could not check that name — it may be reserved, or the service is unreachable.").foregroundColor(.orange) } + else if let q = quote, q.available { Text("Available — \(priceText(q.priceCents))").foregroundColor(.green) } + else { Text("That name is already taken.").foregroundColor(.red) } + } + + private func requote() async { + quote = nil + guard validLabel else { return } + checking = true + try? await Task.sleep(nanoseconds: 400_000_000) + quote = await apiNameQuote(label.trimmingCharacters(in: .whitespaces).lowercased()) + checking = false + } + + private func buy() { + let l = label.trimmingCharacters(in: .whitespaces).lowercased() + Task { + buying = true + defer { buying = false } + let key = "buy:\(ChatModel.shared.remoteHostId ?? -1):\(l):\(years)" + guard let token = NamePayment.purchaseFor(key, years: years) else { return } + let link = pointAtMe ? myLink : nil + if let fqdn = await apiNameBuy(l, years: years, payment: token, link: link) { + NamePayment.spent(key) + if pointAtMe { _ = try? await apiSetUserDomain(fqdn) } + await MainActor.run { + showAlert(NSLocalizedString("The name is yours", comment: "alert"), message: String.localizedStringWithFormat(NSLocalizedString("%@ now belongs to you.", comment: "alert"), fqdn)) + dismiss() + } + } + } + } + + private func priceText(_ cents: Int) -> String { + let total = cents * years + return String(format: "$%d.%02d", total / 100, total % 100) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/GiveNameView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/GiveNameView.swift new file mode 100644 index 0000000000..3c9d778bee --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/GiveNameView.swift @@ -0,0 +1,53 @@ +import SwiftUI +import SimpleXChat + +// 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. +struct GiveNameView: View { + @Environment(\.dismiss) var dismiss + let fqdn: String + private var label: String { fqdn.replacingOccurrences(of: ".simplex", with: "") } + + private var candidates: [Contact] { + ChatModel.shared.chats.compactMap { if case let .direct(c) = $0.chatInfo { return c } else { return nil } } + } + private var canReceive: [Contact] { candidates.filter { $0.profile.metaAddress != nil } } + private var cannotReceive: Int { candidates.count - canReceive.count } + + var body: some View { + List { + Section("Give to") { + if canReceive.isEmpty { + Text("None of your contacts can receive a name yet.").foregroundColor(.secondary) + } else { + ForEach(canReceive) { ct in + Button(ct.profile.profileViewName) { confirm(ct) } + } + } + } footer: { + if cannotReceive == 1 { Text("One of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on.") } + else if cannotReceive > 0 { Text(String.localizedStringWithFormat(NSLocalizedString("%d of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on.", comment: ""), cannotReceive)) } + else { Text("Only contacts who can receive names are listed.") } + } + } + .navigationTitle("Give this name away") + } + + private func confirm(_ ct: Contact) { + showAlert(NSLocalizedString("Give the name away?", comment: "alert"), + message: String.localizedStringWithFormat(NSLocalizedString("%1$@ will belong to %2$@. Only they will be able to change or move it after this.", comment: "alert"), fqdn, ct.profile.profileViewName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Give it away", comment: ""), style: .destructive) { _ in + Task { + if await apiNameGift(label, recipient: "@\(ct.localDisplayName)") { + if let u = try? await apiGetActiveUser() { await MainActor.run { ChatModel.shared.updateUser(u) } } + await MainActor.run { + showAlert(NSLocalizedString("Name given away", comment: "alert"), message: String.localizedStringWithFormat(NSLocalizedString("%1$@ now belongs to %2$@. It appears under names given to them.", comment: "alert"), fqdn, ct.profile.profileViewName)) + dismiss() + } + } + } + }, cancelAlertAction + ]}) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/ImportRecoveryKeyView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/ImportRecoveryKeyView.swift new file mode 100644 index 0000000000..022a99da51 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/ImportRecoveryKeyView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import SimpleXChat + +// Restoring names from a recovery key. A profile that already has a key cannot +// import a different one — the core refuses it — so this says so plainly. +struct ImportRecoveryKeyView: View { + @Environment(\.dismiss) var dismiss + @State private var phrase = "" + @State private var working = false + @State private var hasWallet: Bool? = nil + + private var words: [String] { phrase.split(whereSeparator: { $0.isWhitespace }).map(String.init) } + private var looksComplete: Bool { [12, 15, 18, 21, 24].contains(words.count) } + + var body: some View { + List { + if hasWallet == true { + Section { Text("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.").foregroundColor(.secondary) } + } else { + Section("Recovery key") { + TextField("your recovery words, separated by spaces", text: $phrase, axis: .vertical) + .disabled(working) + } footer: { + if phrase.isEmpty { Text("Enter the words in the order they were shown to you.") } + else if words.count == 1 { Text("1 word entered — a recovery key has 12, 15, 18, 21 or 24.").foregroundColor(looksComplete ? .secondary : .orange) } + else { Text(String.localizedStringWithFormat(NSLocalizedString("%d words entered — a recovery key has 12, 15, 18, 21 or 24.", comment: ""), words.count)).foregroundColor(looksComplete ? .secondary : .orange) } + } + Section { + Button(working ? "Restoring…" : "Restore") { doImport() }.disabled(working || !looksComplete) + } + } + } + .navigationTitle("Restore names from a recovery key") + .task { hasWallet = (await apiNameStatus()).map { if case let .nameStatus(_, w, _, _) = $0 { return w } else { return false } } ?? false } + } + + private func doImport() { + Task { + working = true; defer { working = false } + guard await apiNameSetup("import \(phrase.trimmingCharacters(in: .whitespaces))") else { return } + let found = await apiNameRescan() ?? 0 + await MainActor.run { + let msg = found == 0 ? NSLocalizedString("Recovery key added. Open Your names to see what it holds.", comment: "alert") + : String.localizedStringWithFormat(NSLocalizedString("Recovery key added, and %d names were sent to you. Check Your names and names sent to you.", comment: "alert"), found) + showAlert(NSLocalizedString("Restored", comment: "alert"), message: msg) + dismiss() + } + } + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/IncomingNamesView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/IncomingNamesView.swift new file mode 100644 index 0000000000..eafa6a684c --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/IncomingNamesView.swift @@ -0,0 +1,112 @@ +import SwiftUI +import SimpleXChat + +// Names other people have sent to this profile. Nothing is claimed or shown as +// yours until you accept; declining writes nothing and tells no one. +struct IncomingNamesView: View { + @State private var incoming: [IncomingName] = [] + @State private var canReceive = true + @State private var loading = true + + var body: some View { + List { + if !canReceive && !loading { + Section { + Text("No one can send you a name yet.").foregroundColor(.secondary) + Button("Let people send me names") { enableReceiving() } + } footer: { + Text("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.") + } + } else if incoming.isEmpty { + Section { + Text(loading ? "Checking…" : "No names have been sent to you").foregroundColor(.secondary) + } footer: { + Text("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.") + } + } else { + Section("Waiting for your decision") { + ForEach(incoming, id: \.inAddress) { item in + VStack(alignment: .leading, spacing: 8) { + Text(item.inNames.joined(separator: ", ")).fontWeight(.medium) + HStack(spacing: 16) { + Button("Accept") { accept(item) }.foregroundColor(.accentColor) + Button("Decline", role: .destructive) { decline(item) }.foregroundColor(.red) + } + } + } + } footer: { + Text("Accepting adds the name to your names. Declining removes it from this list and tells no one.") + } + } + + if canReceive { + Section { + Button { rescan() } label: { Label("Check for names sent to you", systemImage: "magnifyingglass") } + .disabled(loading) + } footer: { + Text("Names are normally delivered by message. Check here if you restored this device from your recovery key, or think you missed one.") + } + } + } + .navigationTitle("Names sent to you") + .task { await load() } + } + + private func load() async { + canReceive = (await apiNameStatus()).map { if case let .nameStatus(_, w, _, _) = $0 { return w } else { return false } } ?? false + incoming = canReceive ? (await apiNameIncoming() ?? []) : [] + ChatModel.shared.namesWaiting = incoming.count + loading = false + } + + private func enableReceiving() { + Task { + loading = true + let addr = await apiNameAddress() + await load() + if addr == nil { + await MainActor.run { showAlert(NSLocalizedString("Could not set up names for this profile", comment: "alert")) } + } + } + } + + private func rescan() { + Task { + loading = true + let found = await apiNameRescan() + await load() + if let f = found { + let text = f == 0 ? NSLocalizedString("No new names found.", comment: "alert") + : f == 1 ? NSLocalizedString("Found a name sent to you — see the list above.", comment: "alert") + : String.localizedStringWithFormat(NSLocalizedString("Found %d names sent to you — see the list above.", comment: "alert"), f) + await MainActor.run { showAlert(NSLocalizedString("Check complete", comment: "alert"), message: text) } + } + } + } + + private func accept(_ item: IncomingName) { + showAlert( + NSLocalizedString("Accept these names?", comment: "alert"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ will be added to your names. You can choose to show it on your profile afterwards.", comment: "alert"), item.inNames.joined(separator: ", ")), + actions: {[ + UIAlertAction(title: NSLocalizedString("Accept", comment: ""), style: .default) { _ in + Task { _ = await apiNameAccept(item.inAddress); await load() } + }, + cancelAlertAction + ]} + ) + } + + private func decline(_ item: IncomingName) { + showAlert( + NSLocalizedString("Decline this name?", comment: "alert"), + message: NSLocalizedString("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.", comment: "alert"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Decline", comment: ""), style: .destructive) { _ in + Task { _ = await apiNameDecline(item.inAddress); await load() } + }, + cancelAlertAction + ]} + ) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/NameDetailView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/NameDetailView.swift new file mode 100644 index 0000000000..df2efa0371 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/NameDetailView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import SimpleXChat + +// One name: when it expires, where it points, and what can be done with it. +struct NameDetailView: View { + let fqdn: String + @State private var info: ChatResponse2? = nil + @State private var loadFailed = false + @State private var busy = false + + private var myLink: String? { ChatModel.shared.userAddress?.connLinkContact.simplexChatUri } + + var body: some View { + List { + if case let .nameInfo(_, _, _, contact, channel, expires, credits) = info { + let links = contact + channel + let pointsAtMe = myLink != nil && links.contains(myLink!) + if expiryDays(expires) < 0 { + Section { Text("This name has expired. You have 90 days to extend it and keep it; after that anyone can take it.").foregroundColor(.red) } + } + Section("Points to") { + if links.isEmpty { Text("This name does not point anywhere yet.").foregroundColor(.orange) } + else { ForEach(links, id: \.self) { Text($0).font(.footnote.monospaced()).foregroundColor(.secondary) } } + if !pointsAtMe && myLink != nil { + Button(busy ? "Updating…" : "Point at this profile") { repoint() }.disabled(busy) + } else if pointsAtMe { + Text("This name points at this profile.").foregroundColor(.green) + } + } + Section("How long you have it") { + HStack { Text("Expires"); Spacer(); Text(expiryText(expires)).foregroundColor(expiryColor(expires)) } + HStack { Text("Changes left"); Spacer(); Text("\(credits)").foregroundColor(.secondary) } + } footer: { + Text("Pointing this name somewhere new uses one change. Extending the name adds 10 more.\nNothing renews automatically. After it expires, anyone can take the name.") + } + Section { + Button(busy ? "Extending…" : "Extend this name") { renew() }.disabled(busy).foregroundColor(.accentColor) + Button { addExpiryReminder(expires) } label: { Text("Add expiry to my calendar").foregroundColor(.accentColor) } + NavigationLink { GiveNameView(fqdn: fqdn) } label: { Label("Give this name away", systemImage: "person.crop.circle").foregroundColor(.accentColor) } + } + } else { + Section { Text(loadFailed ? "Could not load your names. Check your connection and reopen this screen." : "Checking…").foregroundColor(loadFailed ? .orange : .secondary) } + } + } + .navigationTitle(fqdn) + .task { await reload() } + } + + private func reload() async { + let r = await apiNameInfo(fqdn) + info = r + loadFailed = r == nil + } + + private func repoint() { + guard let link = myLink else { return } + showAlert(NSLocalizedString("Point at this profile?", comment: "alert"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ 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.", comment: "alert"), fqdn), + actions: {[ + UIAlertAction(title: NSLocalizedString("Point at this profile", comment: ""), style: .default) { _ in + Task { busy = true; defer { busy = false }; if await apiNameSetLink(fqdn, link: link) { await reload() } } + }, cancelAlertAction + ]}) + } + + private func renew() { + showAlert(NSLocalizedString("Extend this name?", comment: "alert"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ will be yours for another year.", comment: "alert"), fqdn), + actions: {[ + UIAlertAction(title: NSLocalizedString("Extend this name", comment: ""), style: .default) { _ in + Task { + busy = true; defer { busy = false } + let key = "renew:\(ChatModel.shared.remoteHostId ?? -1):\(fqdn)" + guard let token = NamePayment.purchaseFor(key, years: 1) else { return } + if let r = await apiNameRenew(fqdn, years: 1, payment: token) { + NamePayment.spent(key); await reload() + let msg = r.reRegistered + ? String.localizedStringWithFormat(NSLocalizedString("%@ had expired, so it was registered again. It is yours for another year.", comment: "alert"), fqdn) + : String.localizedStringWithFormat(NSLocalizedString("%@ is yours for another year.", comment: "alert"), fqdn) + await MainActor.run { showAlert(NSLocalizedString("Extended", comment: "alert"), message: msg) } + } + } + }, cancelAlertAction + ]}) + } + + private func expiryDays(_ e: Int) -> Int { Int(floor(Double(Int64(e) - Int64(Date().timeIntervalSince1970)) / 86400)) } + private func expiryText(_ e: Int) -> String { + let d = expiryDays(e) + if d < 0 { return NSLocalizedString("Expired", comment: "") } + if d == 0 { return NSLocalizedString("Today", comment: "") } + if d == 1 { return NSLocalizedString("Tomorrow", comment: "") } + if d < 30 { return String.localizedStringWithFormat(NSLocalizedString("In %d days", comment: ""), d) } + let f = DateFormatter(); f.dateStyle = .medium + return f.string(from: Date(timeIntervalSince1970: TimeInterval(e))) + } + private func expiryColor(_ e: Int) -> Color { let d = expiryDays(e); return d < 0 ? .red : d < 30 ? .orange : .primary } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/NamePayment.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/NamePayment.swift new file mode 100644 index 0000000000..3e6c9c2a4f --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/NamePayment.swift @@ -0,0 +1,26 @@ +import Foundation + +// Paying for a SimpleX name. This is the seam where StoreKit goes; until then +// `purchaseFor` returns a development token the mock service accepts. Replacing +// this one type with a StoreKit call is the whole client-side billing change. +enum NamePayment { + static let isLive = false + + // Receipts paid for but not yet spent, keyed including the remote host so + // one profile's receipt cannot be handed to another. A registration can + // fail after the charge; holding the receipt means a retry re-submits it + // instead of paying twice. + private static var unspent: [String: String] = [:] + + static func productId(_ years: Int) -> String { "chat.simplex.name.\(years)y" } + + // DEVELOPMENT ONLY: no charge is made and nothing is verified. + static func purchaseFor(_ key: String, years: Int) -> String? { + if let t = unspent[key] { return t } + let t = "dev-mock-payment-\(productId(years))" + unspent[key] = t + return t + } + + static func spent(_ key: String) { unspent[key] = nil } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/NameRecoveryKeyView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/NameRecoveryKeyView.swift new file mode 100644 index 0000000000..f5a0adfb55 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/NameRecoveryKeyView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import SimpleXChat + +// The recovery key for the user's names. For most users this is the only backup +// they have of anything. The stakes are impersonation, not loss: whoever holds +// it can point a name at their own address. +struct NameRecoveryKeyView: View { + @State private var phrase: String? = nil + @State private var loadFailed = false + @State private var saved = false + @State private var revealed = false + + var body: some View { + List { + Section { Text("These words control your names. Anyone who has them controls your names too.").foregroundColor(.secondary) } + + if loadFailed { + Section { Text("Could not load your names. Check your connection and reopen this screen.").foregroundColor(.orange) } + } else if phrase == nil { + Section { Text("You will get a recovery key when you get your first name.").foregroundColor(.secondary) } + } else if !revealed { + Section { + Button { revealed = true } label: { Label("Show recovery key", systemImage: "eye").foregroundColor(.accentColor) } + } footer: { Text("Make sure no one can see your screen.") } + } else { + Section("Your recovery key") { + Text(phrase!).font(.body.monospaced()).fontWeight(.medium) + Button("Copy") { UIPasteboard.general.string = phrase }.foregroundColor(.accentColor) + } footer: { + Text("Write these words down and keep them somewhere safe and offline. Anyone who takes a photo of this screen can take your names.") + } + Section { + if saved { Text("You marked this as saved.").foregroundColor(.secondary) } + else { Button { markSaved() } label: { Label("I have saved it", systemImage: "checkmark").foregroundColor(.accentColor) } } + } + } + + Section("If someone else gets this key") { + Text("They can point your name at their own profile, so people looking for you would find them instead.").foregroundColor(.red) + } + } + .navigationTitle("Recovery key") + .task { + if let r = await apiNameRecoveryKey() { phrase = r.phrase; saved = r.saved } else { loadFailed = true } + } + } + + private func markSaved() { Task { if await apiNameRecoveryKeySaved() { saved = true } } } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/SimplexNamesView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/SimplexNamesView.swift new file mode 100644 index 0000000000..6d965f5288 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/SimplexNamesView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import SimpleXChat + +// Entry point for everything about this profile's SimpleX names: names it holds, +// names sent to it, and the recovery key that survives losing the device. +struct SimplexNamesView: View { + @State private var names: [OwnedName]? = nil + @State private var incomingCount = 0 + @State private var hasKey = false + @State private var keySaved = true + @State private var anySeed = false + @State private var loadFailed = false + + var body: some View { + List { + Section(NSLocalizedString("Your names", comment: "section")) { + if loadFailed { + Text("Could not load your names. Check your connection and reopen this screen.") + .foregroundColor(.orange) + } else if names == nil { + Text("Checking…").foregroundColor(.secondary) + } else if names!.isEmpty { + Text("You do not have a name yet").foregroundColor(.secondary) + } else { + ForEach(names!, id: \.onFqdn) { n in + let expired = Int64(n.onExpires) - Int64(Date().timeIntervalSince1970) < 0 + NavigationLink { + NameDetailView(fqdn: n.onFqdn) + } label: { + HStack { + Text(n.onFqdn).foregroundColor(expired ? .secondary : .primary) + if expired { + Spacer() + Text("expired").foregroundColor(.secondary) + } + } + } + } + } + } footer: { + Text("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.") + } + + Section { + NavigationLink { BuyNameView() } label: { + Label("Buy a name", systemImage: "plus").foregroundColor(.accentColor) + } + NavigationLink { IncomingNamesView() } label: { + HStack { + Label("Names sent to you", systemImage: "envelope") + if incomingCount > 0 { + Spacer() + Text("\(incomingCount)").foregroundColor(.accentColor) + } + } + } + if !hasKey { + NavigationLink { WalletSetupView(hasOtherSeed: anySeed) } label: { + Label("Set up names for this profile", systemImage: "plus").foregroundColor(.accentColor) + } + } + NavigationLink { ImportRecoveryKeyView() } label: { + Label("Restore names from a recovery key", systemImage: "square.and.arrow.down") + } + if hasKey { + NavigationLink { NameRecoveryKeyView() } label: { + Label("Recovery key", systemImage: "key") + .foregroundColor(keySaved ? .primary : .orange) + } + } + } + } + .navigationTitle("SimpleX names") + .task { await load() } + } + + private func load() async { + // Status first, and nothing else until it confirms a wallet: every other + // call creates the wallet as a side effect, so asking for the list first + // would give a wallet to anyone who merely opened this screen. + let st = await apiNameStatus() + loadFailed = st == nil + if case let .nameStatus(_, hasWallet, saved, seed) = st { + hasKey = hasWallet + keySaved = saved + anySeed = seed + if hasWallet { + names = await apiNameList() + incomingCount = (await apiNameIncoming())?.count ?? 0 + ChatModel.shared.namesWaiting = incomingCount + } else { + names = [] + } + } + } +} diff --git a/apps/ios/Shared/Views/UserSettings/SimplexNames/WalletSetupView.swift b/apps/ios/Shared/Views/UserSettings/SimplexNames/WalletSetupView.swift new file mode 100644 index 0000000000..b6550f638a --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/SimplexNames/WalletSetupView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import SimpleXChat + +// How this profile gets its recovery key, asked once. 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. +struct WalletSetupView: View { + @Environment(\.dismiss) var dismiss + let hasOtherSeed: Bool + @State private var busy = false + + var body: some View { + List { + Section { + if hasOtherSeed { + Button { setup("existing") } label: { Label("Use the recovery key I already have", systemImage: "checkmark").foregroundColor(.accentColor) } + } + Button { setup("new") } label: { + Label(hasOtherSeed ? "Give this profile its own recovery key" : "Create a recovery key", systemImage: "plus").foregroundColor(.accentColor) + } + NavigationLink { ImportRecoveryKeyView() } label: { Label("Restore names from a recovery key", systemImage: "square.and.arrow.down") } + } footer: { + Text(hasOtherSeed + ? "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." + : "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.") + } + } + .navigationTitle("Set up names for this profile") + } + + private func setup(_ arg: String) { + Task { busy = true; defer { busy = false }; if await apiNameSetup(arg) { await MainActor.run { dismiss() } } } + } +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ddc9454b86..4a3b6ce705 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -122,7 +122,8 @@ public struct Profile: Codable, NamedChat, Hashable { contactLink: String? = nil, preferences: Preferences? = nil, peerType: ChatPeerType? = nil, - contactDomain: SimplexDomainClaim? = nil + contactDomain: SimplexDomainClaim? = nil, + metaAddress: String? = nil ) { self.displayName = displayName self.fullName = fullName @@ -132,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.contactLink = contactLink self.preferences = preferences self.contactDomain = contactDomain + self.metaAddress = metaAddress } public var displayName: String @@ -146,6 +148,10 @@ public struct Profile: Codable, NamedChat, Hashable { public var badge: BadgeProof? public var localAlias: String { get { "" } } public var contactDomain: SimplexDomainClaim? + // 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. + public var metaAddress: String? public var profileDescription: String? { description } @@ -173,7 +179,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable { localBadge: LocalBadge? = nil, localAlias: String, contactDomain: SimplexDomainClaim? = nil, - contactDomainVerified: Bool? = nil + contactDomainVerified: Bool? = nil, + metaAddress: String? = nil ) { self.profileId = profileId self.displayName = displayName @@ -188,6 +195,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { self.localAlias = localAlias self.contactDomain = contactDomain self.contactDomainVerified = contactDomainVerified + self.metaAddress = metaAddress } public var profileId: Int64 @@ -203,6 +211,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var localAlias: String public var contactDomain: SimplexDomainClaim? public var contactDomainVerified: Bool? + public var metaAddress: String? public var profileDescription: String? { description } @@ -2725,6 +2734,26 @@ public struct PublicGroupAccess: Codable, Hashable { public var allowEmbedding: Bool = false } +// What was handed over in an in-app transfer: a name now, a token later. +// `kind` selects the wording and the screen to open. `ephemeralPubKey` lets the +// recipient record the destination on receipt instead of scanning. +public struct AssetTransfer: Codable, Hashable { + public var kind: String + public var asset: String + public var ephemeralPubKey: String? + public static let kindSimplexName = "simplexName" +} + +public struct OwnedName: Codable, Hashable { + public var onFqdn: String + public var onExpires: Int +} + +public struct IncomingName: Codable, Hashable { + public var inAddress: String + public var inNames: [String] +} + public struct SimplexDomainClaim: Codable, Hashable { public init(domain: String, proof: SimplexDomainProof? = nil) { self.domain = domain @@ -4944,6 +4973,7 @@ public enum MsgContent: Equatable, Hashable { case file(String) case report(text: String, reason: ReportReason) case chat(text: String, chatLink: MsgChatLink, ownerSig: LinkOwnerSig?) + case assetTransfer(text: String, transfer: AssetTransfer) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -4961,6 +4991,7 @@ public enum MsgContent: Equatable, Hashable { case let .file(text): return text case let .report(text, _): return text case let .chat(text, _, _): return text + case let .assetTransfer(text, _): return text case let .unknown(_, text): return text } } @@ -5024,12 +5055,14 @@ public enum MsgContent: Equatable, Hashable { case reason case chatLink case ownerSig + case transfer } public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool { switch (lhs, rhs) { case let (.text(lt), .text(rt)): return lt == rt case let (.link(lt, lp), .link(rt, rp)): return lt == rt && lp == rp + case let (.assetTransfer(lt, lx), .assetTransfer(rt, rx)): return lt == rt && lx == rx case let (.image(lt, li), .image(rt, ri)): return lt == rt && li == ri case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd @@ -5080,6 +5113,10 @@ extension MsgContent: Decodable { let chatLink = try container.decode(MsgChatLink.self, forKey: CodingKeys.chatLink) let ownerSig = try container.decodeIfPresent(LinkOwnerSig.self, forKey: CodingKeys.ownerSig) self = .chat(text: text, chatLink: chatLink, ownerSig: ownerSig) + case "assetTransfer": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let transfer = try container.decode(AssetTransfer.self, forKey: CodingKeys.transfer) + self = .assetTransfer(text: text, transfer: transfer) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -5126,6 +5163,10 @@ extension MsgContent: Encodable { try container.encode(text, forKey: .text) try container.encode(chatLink, forKey: .chatLink) try container.encodeIfPresent(ownerSig, forKey: .ownerSig) + case let .assetTransfer(text, transfer): + try container.encode("assetTransfer", forKey: .type) + try container.encode(text, forKey: .text) + try container.encode(transfer, forKey: .transfer) // TODO use original JSON and type case let .unknown(_, text): try container.encode("text", forKey: .type) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Calendar.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Calendar.android.kt new file mode 100644 index 0000000000..d65db7a6d2 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Calendar.android.kt @@ -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 + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 340ba2c1ad..ff420000a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -153,6 +153,8 @@ object ChatModel { var terminalsVisible = setOf() val terminalItems = mutableStateOf>(listOf()) val userAddress = mutableStateOf(null) + // Names sent to this profile and not yet accepted or declined. + val namesWaiting = mutableStateOf(0) val chatItemTTL = mutableStateOf(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 { } } +/** + * 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 +) + @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 { val reason = decoder.json.decodeFromString(json["reason"].toString()) MsgContent.MCReport(text, reason) } + "assetTransfer" -> { + val t2 = decoder.json.decodeFromString(json["transfer"].toString()) + MsgContent.MCAssetTransfer(text, t2) + } "chat" -> { val chatLink = decoder.json.decodeFromString(json["chatLink"].toString()) val ownerSig = json["ownerSig"]?.let { decoder.json.decodeFromJsonElement(it) } @@ -4720,6 +4767,12 @@ object MsgContentSerializer : KSerializer { 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") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/NamePayment.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/NamePayment.kt new file mode 100644 index 0000000000..7b2de0abda --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/NamePayment.kt @@ -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() + + /** 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) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 037c02b1c2..fdbe6f6b1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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 " - 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? { + 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? { + 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? { + 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?{ 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, val nameChannel: List, 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): CR() + @Serializable @SerialName("nameAccepted") class NameAccepted(val user: User, val nameOneTimeAddr: String, val acceptedNames: List): 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): 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)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Calendar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Calendar.kt new file mode 100644 index 0000000000..e178116c20 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Calendar.kt @@ -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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 40d7927264..d10fac2773 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..4028ffed23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -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)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 7e66c15937..027f6bceb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/BuyNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/BuyNameView.kt new file mode 100644 index 0000000000..dc17b2c777 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/BuyNameView.kt @@ -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(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, + years: MutableState, + quote: CR.NameQuoted?, + checking: Boolean, + buying: Boolean, + pointAtMe: MutableState, + 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')}" +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/GiveNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/GiveNameView.kt new file mode 100644 index 0000000000..75a06fe134 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/GiveNameView.kt @@ -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, 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 } + } + } + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ImportRecoveryKeyView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ImportRecoveryKeyView.kt new file mode 100644 index 0000000000..12244b3cc8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ImportRecoveryKeyView.kt @@ -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(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() + } +} + + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncomingNamesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncomingNamesView.kt new file mode 100644 index 0000000000..1425bf7526 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncomingNamesView.kt @@ -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()) } + 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, + 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() + } + } + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameDetailView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameDetailView.kt new file mode 100644 index 0000000000..fe5a68d324 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameDetailView.kt @@ -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(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 +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameRecoveryKeyView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameRecoveryKeyView.kt new file mode 100644 index 0000000000..3692007bf2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NameRecoveryKeyView.kt @@ -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(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() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c8e040c592..106f0309bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SimplexNamesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SimplexNamesView.kt new file mode 100644 index 0000000000..1aa08ed3a3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SimplexNamesView.kt @@ -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?>(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?, + 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() + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/WalletSetupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/WalletSetupView.kt new file mode 100644 index 0000000000..9cef0fb9e4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/WalletSetupView.kt @@ -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() + } +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7950e7cc1c..5c44c2126a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3198,4 +3198,161 @@ This badge could not be verified and may not be genuine. Badge cannot be verified The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + Names sent to you + Waiting for your decision + No names have been sent to you + Checking… + Accepting adds the name to your names. Declining removes it from this list and tells no one. + Accept + Decline + Accept this name? + %s will be added to your names. You can choose to show it on your profile afterwards. + Could not accept the name + Decline this name? + 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. + Could not decline the name + Could not give the name away + Check for names sent to you + Names are normally delivered by message. Check here if you restored this device from your recovery key, or think you missed one. + Check complete + No new names found. + Found %d names sent to you — see the list above. + Found a name sent to you — see the list above. + SimpleX names + Your names + You do not have a name yet + 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. + Recovery key + Your recovery key + These words control your names. Anyone who has them controls your names too. + You will get a recovery key when you get your first name. + Nothing to back up yet. + Show recovery key + Make sure no one can see your screen. + Copy + Write these words down and keep them somewhere safe and offline. Anyone who takes a photo of this screen can take your names. + I have saved it + You marked this as saved. + If someone else gets this key + They can point your name at their own profile, so people looking for you would find them instead. + 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. + Buy a name + Choose a name + yourname + Letters, digits and hyphens. At least 6 characters. + How long + %d years + You pay once for the whole period. Nothing renews automatically, and the app will not remind you — note the end date. + A bit short — names need at least 6 characters. + Checking… + Could not check that name — it may be reserved, or the service is unreachable. + That name is already taken. + Buy this name — %s + Registering… + Could not buy the name + The name is yours + %s now points at this profile. + Save recovery key + Later + Development build + Test build: no payment is taken and nothing is registered. Names disappear when the app closes. + No one can send you a name yet. + Let people send me names + 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. + You can now be sent names + 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. + Available + Use letters, digits and single hyphens between them. + Available — %s + Check the name + 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. + Points to + This name does not point anywhere yet. + This name does not point at this profile, so it will not bring anyone to you. + This name points at this profile. + How long you have it + Expires + Expired + Today + Changes left + Nothing renews automatically. If it expires you still have 90 days to extend it; after that anyone can take it. + Point at this profile? + %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. + Point at this profile + Updating… + Could not update the name + Give this name away + %s will become theirs. You will not be able to take it back. + Give to + None of your contacts can receive a name yet. + Only contacts who can receive names are listed. + %d of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on. + One of your contacts cannot receive names yet — they need a newer app version, or to turn receiving on. + Give the name away? + %1$s will belong to %2$s. Only they will be able to change or move it after this. + Give it away + Name given away + %1$s is now %2$s\'s. They will see it in SimpleX names. + Point this name at your profile? + %s will point at this profile\'s address, so anyone using the name reaches you. Your address becomes part of a public record. + Buy it + Use this as your SimpleX name? + %s will be shown on your profile, and people can find you by it instead of a link. + Use it + Could not set your SimpleX name + Point this name at my profile + People using the name will reach this profile. + The name will point nowhere. Buy it this way if it is for someone else — you can give it to them afterwards. + Buy this name? + %s will be registered without pointing anywhere. You can give it to someone from your names list. + %s is registered and points nowhere yet. Give it to someone from your names list. + Shown on your profile + not managed here + This name is shown on your profile, but this app does not manage it. + This name is shown on your profile, but this app does not manage it — it cannot be given away or re-pointed from here. + 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. + You sent the SimpleX name %s + You were sent the SimpleX name %s — open SimpleX names to accept it + You were sent a SimpleX name. Update SimpleX to accept it. + Restore names from a recovery key + 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. + Recovery key + your recovery words, separated by spaces + Enter the words in the order they were shown to you. + %d words entered — a recovery key has 12, 15, 18, 21 or 24. + 1 word entered — a recovery key has 12, 15, 18, 21 or 24. + Restore + Restoring… + That recovery key was not accepted + Restored + Recovery key added. Open Your names to see what it holds. + Recovery key added, and %d names were sent to you. Check Your names and names sent to you. + In %d days + Tomorrow + Pointing this name somewhere new uses one change. Extending the name adds 10 more. + Extend this name + Extend this name? + %s will be yours for another year. + Extended + %s is yours for another year. + %s had expired, so it was registered again. It is yours for another year. + Could not extend the name + This name has expired. You have 90 days to extend it and keep it; after that anyone can take it. + expired + Add expiry to my calendar + SimpleX name %s expires + Extend it in SimpleX to keep it. You have 90 days after it expires; after that anyone can take it. + No calendar app to add a reminder to. + 1 year + Could not load your names. Check your connection and reopen this screen. + %s is yours, but it is not shown on your profile yet — you need a SimpleX address first. + Set up names for this profile + Names are kept safe by a recovery key. Choose how this profile gets one. + Use the recovery key I already have + Give this profile its own recovery key + Create a recovery key + 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. + 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. + Could not set up names for this profile + 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. \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Calendar.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Calendar.desktop.kt new file mode 100644 index 0000000000..e44439f2a2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Calendar.desktop.kt @@ -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