Files
simplex-chat/apps/ios/Shared/Views/UserSettings/SimplexNames/BuyNameView.swift
T
Alain Brenzikofer 31cd2619da 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.
2026-08-17 10:24:27 +02:00

130 lines
5.7 KiB
Swift

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)
}
}