Files
simplex-chat/apps/ios/Shared/Views/UserSettings/UserAddressView.swift
T
Evgeny @ SimpleX Chat 17c9ad1c61 ios, android, desktop: previews render the same in every snapshot run (#7938)
* ios, android, desktop: previews draw fixed sample data instead of calling the core

* desktop: database error preview passes the keychain setting

* ios: snapshot fixtures use fixed parsed markdown instead of the core

* ios: snapshot fixtures: raw string delimiters past the # in markdown JSON

* desktop: snapshot test sets the app bar height as the app does

* docs: plan to replace preview-only parameters with test measures

* ios, desktop: previews run master's code; badges and version views split

Preview-only parameters are removed. BadgesCheckOrderView, BadgesHowLongView and VersionView are split into a loader and a layout, which their previews draw. Snapshot tests set a fixed QR picture: the environment value fixedQRCode on iOS, LocalFixedQRCode on desktop.

* ios: snapshot fixtures build parsed markdown with initializers

The table held the JSON of the Linux core, which the iOS app does not decode. The values are those the core returns for each text.

* ci: references of fixed QR codes and version; ios: call overlay preview as on master

iOS references from https://github.com/evgeny-simplex/simplex-chat/actions/runs/40944118370, desktop references from https://github.com/evgeny-simplex/simplex-chat/actions/runs/40932566864. ActiveCallOverlay_Previews, its scene and its references are restored as on master.

* ios, desktop: snapshot tests render every preview, no exclusion lists

BadgesChooseBadgeView, ServersSummaryView and TerminalView are split into a loader and a layout. AddChannelView, AddGroupView, GroupProfileView and UserProfileView layouts take the name and description validators, so desktop previews render without the core library. NewChatView's preview opens the Connect tab, UserProfilesView's preview has its own users. AppearanceSettings, MigrateFromDevice and the duplicate UserProfileLayoutEditOn previews are deleted.

* ios, desktop: previews show short links; tests draw the fixed QR code with each app's generator

The snapshot tests draw the fixed QR picture for a short address link with generateImage on iOS and qrCodeBitmap on desktop, in place of committed images. Previews of group links, addresses, a pending connection and QR codes pass short links, as the apps show them. The fixture markdown table equals the output of master's parser.

* ios, desktop: excluded previews and their views as on master

* ci: references of short-link QR codes
2026-10-30 06:41:40 +00:00

1040 lines
42 KiB
Swift

//
// UserAddressView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 26.04.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import MessageUI
@preconcurrency import SimpleXChat
struct UserAddressView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false
@State var autoCreate = false
var onboarding: Bool = false
@State private var showShortLink = true
@State private var settings = AddressSettingsState()
@State private var savedSettings = AddressSettingsState()
@State private var showMailView = false
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
@State private var alert: UserAddressAlert?
@State private var progressIndicator = false
@State private var showShareViaChat = false
private enum UserAddressAlert: Identifiable {
case deleteAddress
case shareOnCreate
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
case .deleteAddress: return "deleteAddress"
case .shareOnCreate: return "shareOnCreate"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
ZStack {
userAddressView()
if progressIndicator {
ZStack {
if chatModel.userAddress != nil {
Circle()
.fill(.white)
.opacity(0.7)
.frame(width: 56, height: 56)
}
ProgressView().scaleEffect(2)
}
}
}
.if(onboarding) { v in
v.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Image(systemName: "info.circle").opacity(0)
}
}
.navigationBarTitleDisplayMode(.inline)
}
.onAppear {
if chatModel.userAddress == nil, autoCreate {
createAddress()
}
}
}
private func userAddressView() -> some View {
List {
if let userAddress = chatModel.userAddress {
if onboarding {
onboardingAddressView(userAddress)
} else {
existingAddressView(userAddress)
.onAppear {
settings = AddressSettingsState(settings: userAddress.addressSettings)
savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
}
}
} else if !onboarding {
Section {
createAddressButton()
} header: {
Text("For social media")
.foregroundColor(theme.colors.secondary)
}
Section {
createOneTimeLinkButton()
} header: {
Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
Section {
learnMoreButton()
}
}
}
.alert(item: $alert) { alert in
switch alert {
case .deleteAddress:
return Alert(
title: Text("Delete address?"),
message:
shareViaProfile
? Text("All your contacts will remain connected. Profile update will be sent to your contacts.")
: Text("All your contacts will remain connected."),
primaryButton: .destructive(Text("Delete")) {
progressIndicator = true
Task {
do {
if let u = try await apiDeleteUserAddress() {
DispatchQueue.main.async {
chatModel.userAddress = nil
chatModel.updateUser(u)
if shareViaProfile {
shareViaProfile = false
}
}
}
await MainActor.run { progressIndicator = false }
} catch let error {
logger.error("UserAddressView apiDeleteUserAddress: \(responseError(error))")
await MainActor.run { progressIndicator = false }
}
}
}, secondaryButton: .cancel()
)
case .shareOnCreate:
return Alert(
title: Text("Share address with SimpleX contacts?"),
message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."),
primaryButton: .default(Text("Share")) {
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
shareViaProfile = true
}, secondaryButton: .cancel()
)
case let .error(title, error):
return mkAlert(title: title, message: error)
}
}
}
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section {
SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
.id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
if userAddress.shouldBeUpgraded {
upgradeAddressButton()
}
shareAddressButton(userAddress)
shareViaChatButton(userAddress)
// if MFMailComposeViewController.canSendMail() {
// shareViaEmailButton(userAddress)
// }
settingsRow("briefcase", color: theme.colors.secondary) {
Toggle("Business address", isOn: $settings.businessAddress)
.onChange(of: settings.businessAddress) { ba in
if ba {
settings.autoAccept = true
settings.autoAcceptIncognito = false
}
saveAddressSettings(settings, $savedSettings)
}
}
addressSettingsButton(userAddress)
} header: {
#if SIMPLEX_ASSETS
VStack(alignment: .leading, spacing: 0) {
Image(colorScheme == .light ? "simplex-address-small" : "simplex-address-small-light")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.padding(.top, -20)
ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
}
.padding(.bottom, 4)
#else
ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
#endif
} footer: {
if settings.businessAddress {
Text("Add your team members to the conversations.")
.foregroundColor(theme.colors.secondary)
}
}
Section {
NavigationLink {
let simplexName = if let d = chatModel.currentUser?.profile.contactDomain?.domain { "@\(d)" } else { "" }
SetSimplexDomainView(
title: "Your SimpleX name",
footer: "Let people connect to you via name registered with your SimpleX address.",
prompt: "@yourname.testing",
simplexName: simplexName,
broadcastWarning: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert title"),
save: { simplexDomain in
do {
let u = try await apiSetUserDomain(simplexDomain)
await MainActor.run { chatModel.updateUser(u) }
return true
} catch {
return false
}
}
)
} label: {
if let d = chatModel.currentUser?.profile.contactDomain?.domain {
Label("\(d)", systemImage: "at")
} else {
Label("Get SimpleX name (BETA)", systemImage: "at")
}
}
} header: {
if chatModel.currentUser?.profile.contactDomain?.domain != nil {
Text("Your SimpleX name").foregroundColor(theme.colors.secondary)
}
}
Section {
createOneTimeLinkButton()
} header: {
Text("Or to share privately")
.foregroundColor(theme.colors.secondary)
}
Section {
learnMoreButton()
}
Section {
deleteAddressButton()
} footer: {
Text("Your contacts will remain connected.")
.foregroundColor(theme.colors.secondary)
}
}
@ViewBuilder private func onboardingAddressView(_ userAddress: UserContactLink) -> some View {
Section {
HStack(spacing: 8) {
let link = userAddress.connLinkContact.simplexChatUri(short: showShortLink)
linkTextView(link)
Button { showShareSheet(items: [link]) } label: {
Image(systemName: "square.and.arrow.up")
.padding(.top, -7)
.padding(.horizontal, 8)
}
}
.frame(maxWidth: .infinity)
} header: {
#if SIMPLEX_ASSETS
VStack(alignment: .leading) {
Image(colorScheme == .light ? "simplex-address" : "simplex-address-light")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
Text("Use this address in your social media profile, website, or email signature.")
.font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
}
.padding(.bottom, 4)
#else
Text("Use this address in your social media profile, website, or email signature.")
.font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
.padding(.bottom, 6)
#endif
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
Section {
SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
.id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
.padding()
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} header: {
Text("Or use this QR - print or show online.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil)
}
}
private func createAddressButton() -> some View {
Button {
createAddress()
} label: {
Label("Create SimpleX address", systemImage: "qrcode")
}
}
private func createAddress() {
progressIndicator = true
Task {
do {
let connLinkContact = try await apiCreateUserAddress()
DispatchQueue.main.async {
if let connLinkContact {
chatModel.userAddress = UserContactLink(connLinkContact)
let hasRelevantContacts = chatModel.chats.contains { chat in
if case let .direct(contact) = chat.chatInfo {
return contact.active && !contact.isContactCard && !contact.contactConnIncognito
}
return false
}
if hasRelevantContacts {
alert = .shareOnCreate
progressIndicator = false
} else {
setProfileAddress($progressIndicator, true) { shareViaProfile = false }
shareViaProfile = true
}
} else {
progressIndicator = false
}
}
} catch let error {
logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))")
await MainActor.run {
progressIndicator = false
showErrorAlert(error, NSLocalizedString("Error creating address", comment: ""))
}
}
}
}
private func upgradeAddressButton() -> some View {
Button {
upgradeAndShareAddressAlert(progressIndicator: $progressIndicator)
} label: {
settingsRow("arrow.up", color: theme.colors.primary) {
Text("Upgrade address")
}
}
}
private func createOneTimeLinkButton() -> some View {
NavigationLink {
NewChatView(selection: .invite)
.navigationTitle("New chat")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Label("Create 1-time link", systemImage: "link.badge.plus")
.foregroundColor(theme.colors.primary)
}
}
private func deleteAddressButton() -> some View {
Button(role: .destructive) {
alert = .deleteAddress
} label: {
Label("Delete address", systemImage: "trash")
.foregroundColor(Color.red)
}
}
private func shareAddressButton(_ userAddress: UserContactLink) -> some View {
return Button {
if userAddress.shouldBeUpgraded {
upgradeAndShareAddressAlert(progressIndicator: $progressIndicator, shareAddress: { userAddress.shareAddress(short: showShortLink) })
} else {
userAddress.shareAddress(short: showShortLink)
}
} label: {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Text("Share address")
}
}
}
private func shareViaChatButton(_ userAddress: UserContactLink) -> some View {
Button {
if userAddress.shouldBeUpgraded {
showAlert(
NSLocalizedString("Upgrade address?", comment: "alert title"),
message: NSLocalizedString("The address will be short, and your profile will be shared via the address.", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in
addShortLink(progressIndicator: $progressIndicator, onComplete: { showShareViaChat = true })
},
cancelAlertAction
]}
)
} else {
showShareViaChat = true
}
} label: {
settingsRow("arrowshape.turn.up.forward", color: theme.colors.primary) {
Text("Share via chat").foregroundColor(theme.colors.primary)
}
}
.sheet(isPresented: $showShareViaChat) {
shareAddressPicker()
}
}
private func shareViaEmailButton(_ userAddress: UserContactLink) -> some View {
Button {
showMailView = true
} label: {
settingsRow("envelope", color: theme.colors.secondary) {
Text("Invite friends")
}
}
.sheet(isPresented: $showMailView) {
SendAddressMailView(
showMailView: $showMailView,
mailViewResult: $mailViewResult,
userAddress: userAddress
)
.edgesIgnoringSafeArea(.bottom)
}
.onChange(of: mailViewResult == nil) { _ in
if let r = mailViewResult {
switch r {
case .success: ()
case let .failure(error):
logger.error("UserAddressView share via email: \(responseError(error))")
showErrorAlert(error, NSLocalizedString("Error sending email", comment: ""))
}
mailViewResult = nil
}
}
}
private func addressSettingsButton(_ userAddress: UserContactLink) -> some View {
NavigationLink {
UserAddressSettingsView(shareViaProfile: $shareViaProfile)
.navigationTitle("Address settings")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Address settings")
}
}
private func learnMoreButton() -> some View {
NavigationLink {
UserAddressLearnMore()
.navigationTitle("Address or 1-time link?")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.inline)
} label: {
settingsRow("info.circle", color: theme.colors.secondary) {
Text("SimpleX address or 1-time link?")
}
}
}
}
func upgradeAndShareAddressAlert(progressIndicator: Binding<Bool>, shareAddress: (() -> Void)? = nil) {
showAlert(
NSLocalizedString("Upgrade address?", comment: "alert message"),
message: NSLocalizedString("The address will be short, and your profile will be shared via the address.", comment: "alert message"),
actions: {
var actions = [UIAlertAction(title: NSLocalizedString("Upgrade", comment: "alert button"), style: .default) { _ in
addShortLink(progressIndicator: progressIndicator, shareOnCompletion: shareAddress != nil)
}]
if let shareAddress {
actions.append(UIAlertAction(title: NSLocalizedString("Share old address", comment: "alert button"), style: .default) { _ in
shareAddress()
})
}
actions.append(cancelAlertAction)
return actions
}
)
}
@ViewBuilder
func shareAddressPicker(composeState: Binding<ComposeState>? = nil) -> some View {
let v = ChatItemForwardingView(
title: "Share address",
isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) },
onSelectChat: { chat in shareMyAddress(chat, composeState: composeState) },
includeLocal: false
)
if #available(iOS 16.0, *) {
v.presentationDetents([.fraction(0.8)])
} else {
v
}
}
func shareMyAddress(_ destChat: Chat, composeState: Binding<ComposeState>? = nil) {
let sendAsGroup = if let gInfo = destChat.chatInfo.groupInfo { gInfo.useRelays && gInfo.membership.memberRole >= .owner } else { false }
Task {
do {
let mc = try await apiShareMyAddress(
toChatType: destChat.chatInfo.chatType, toChatId: destChat.chatInfo.apiId,
toScope: destChat.chatInfo.groupChatScope(), sendAsGroup: sendAsGroup
)
if case let .chat(_, chatLink, ownerSig) = mc {
await MainActor.run {
dismissAllSheets {
let cs = ComposeState(preview: .chatLinkPreview(chatLink: chatLink, ownerSig: ownerSig))
if let composeState {
composeState.wrappedValue = cs
} else {
ChatModel.shared.draft = cs
ChatModel.shared.draftChatId = destChat.id
}
if destChat.id != ChatModel.shared.chatId {
ItemsModel.shared.loadOpenChat(destChat.id)
}
}
}
} else {
logger.error("shareMyAddress: unexpected MsgContent: \(String(describing: mc))")
await MainActor.run {
showAlert(NSLocalizedString("Error sharing address", comment: "alert title"), message: String(describing: mc))
}
}
} catch {
logger.error("shareMyAddress error: \(error.localizedDescription)")
await MainActor.run {
showAlert(NSLocalizedString("Error sharing address", comment: "alert title"), message: error.localizedDescription)
}
}
}
}
private func addShortLink(progressIndicator: Binding<Bool>, shareOnCompletion: Bool = false, onComplete: (() -> Void)? = nil) {
progressIndicator.wrappedValue = true
Task {
do {
let userAddress = try await apiAddMyAddressShortLink()
await MainActor.run {
ChatModel.shared.userAddress = userAddress
progressIndicator.wrappedValue = false
if shareOnCompletion, let userAddress {
userAddress.shareAddress(short: true)
}
onComplete?()
}
} catch let error {
logger.error("apiAddMyAddressShortLink: \(responseError(error))")
showAlert("Error adding short link", message: responseError(error))
await MainActor.run { progressIndicator.wrappedValue = false }
}
}
}
struct ToggleShortLinkHeader: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
let text: Text
var link: CreatedConnLink
@Binding var short: Bool
var body: some View {
if link.connShortLink == nil || !developerTools {
text.foregroundColor(theme.colors.secondary)
} else {
HStack {
text.foregroundColor(theme.colors.secondary)
Spacer()
Text(short ? "Full link" : "Short link")
.textCase(.none)
.foregroundColor(theme.colors.primary)
.onTapGesture { short.toggle() }
}
}
}
}
struct AddressSettingsState: Equatable {
var businessAddress = false
var autoAccept = false
var autoAcceptIncognito = false
var autoReply = ""
var chatLayout: ChatLayout?
init() {}
init(settings: AddressSettings) {
self.businessAddress = settings.businessAddress
self.autoAccept = settings.autoAccept != nil
self.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true
self.autoReply = settings.autoReply?.text ?? ""
self.chatLayout = settings.chatLayout
}
var addressSettings: AddressSettings {
AddressSettings(
businessAddress: self.businessAddress,
autoAccept: self.autoAccept ? AutoAccept(acceptIncognito: self.autoAcceptIncognito) : nil,
autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply),
chatLayout: self.chatLayout
)
}
}
private func setProfileAddress(_ progressIndicator: Binding<Bool>, _ on: Bool, revert: @escaping () -> Void) {
progressIndicator.wrappedValue = true
Task {
do {
if let u = try await apiSetProfileAddress(on: on) {
DispatchQueue.main.async {
ChatModel.shared.updateUser(u)
}
}
await MainActor.run { progressIndicator.wrappedValue = false }
} catch let error {
logger.error("apiSetProfileAddress: \(responseError(error))")
await MainActor.run {
progressIndicator.wrappedValue = false
revert()
showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title"))
}
}
}
}
struct UserAddressSettingsView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@Binding var shareViaProfile: Bool
@State private var settings = AddressSettingsState()
@State private var savedSettings = AddressSettingsState()
@State private var ignoreShareViaProfileChange = false
@State private var progressIndicator = false
var body: some View {
ZStack {
if let userAddress = ChatModel.shared.userAddress {
userAddressSettingsView()
.onAppear {
settings = AddressSettingsState(settings: userAddress.addressSettings)
savedSettings = AddressSettingsState(settings: userAddress.addressSettings)
}
.onChange(of: settings.autoAccept) { autoAccept in
if !autoAccept {
settings.businessAddress = false
settings.autoReply = ""
}
}
.onDisappear {
if savedSettings != settings {
showAlert(
title: NSLocalizedString("SimpleX address settings", comment: "alert title"),
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: { saveAddressSettings(settings, $savedSettings) },
cancelButton: true
)
}
}
} else {
Text(String("Error opening address settings"))
}
if progressIndicator {
ProgressView().scaleEffect(2)
}
}
}
private func userAddressSettingsView() -> some View {
List {
Section {
shareWithContactsButton()
autoAcceptToggle().disabled(settings.businessAddress)
if settings.autoAccept && !ChatModel.shared.addressShortLinkDataSet && !settings.businessAddress {
acceptIncognitoToggle()
}
}
Section {
messageEditor(placeholder: NSLocalizedString("Enter welcome message… (optional)", comment: "placeholder"), text: $settings.autoReply)
} header: {
Text("Welcome message")
.foregroundColor(theme.colors.secondary)
}
Section {
saveAddressSettingsButton()
.disabled(settings == savedSettings)
}
}
}
private func shareWithContactsButton() -> some View {
settingsRow("person", color: theme.colors.secondary) {
Toggle("Share with SimpleX contacts", isOn: $shareViaProfile)
.onChange(of: shareViaProfile) { on in
if ignoreShareViaProfileChange {
ignoreShareViaProfileChange = false
} else {
if on {
showAlert(
NSLocalizedString("Share address with SimpleX contacts?", comment: "alert title"),
message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .cancel,
handler: { _ in
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
),
UIAlertAction(
title: NSLocalizedString("Share", comment: "alert action"),
style: .default,
handler: { _ in
setProfileAddress($progressIndicator, on) {
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
}
)
]}
)
} else {
showAlert(
NSLocalizedString("Stop sharing address?", comment: "alert title"),
message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .cancel,
handler: { _ in
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
),
UIAlertAction(
title: NSLocalizedString("Stop sharing", comment: "alert action"),
style: .default,
handler: { _ in
setProfileAddress($progressIndicator, on) {
ignoreShareViaProfileChange = true
shareViaProfile = !on
}
}
)
]}
)
}
}
}
}
}
private func autoAcceptToggle() -> some View {
settingsRow("checkmark", color: theme.colors.secondary) {
Toggle("Auto-accept", isOn: $settings.autoAccept)
.onChange(of: settings.autoAccept) { _ in
saveAddressSettings(settings, $savedSettings)
}
}
}
private func acceptIncognitoToggle() -> some View {
settingsRow(
settings.autoAcceptIncognito ? "theatermasks.fill" : "theatermasks",
color: settings.autoAcceptIncognito ? .indigo : theme.colors.secondary
) {
Toggle("Accept incognito", isOn: $settings.autoAcceptIncognito)
}
}
private func messageEditor(placeholder: String, text: Binding<String>) -> some View {
ZStack {
Group {
if text.wrappedValue.isEmpty {
TextEditor(text: Binding.constant(placeholder))
.foregroundColor(theme.colors.secondary)
.disabled(true)
}
TextEditor(text: text)
}
.padding(.horizontal, -5)
.padding(.top, -8)
.frame(height: 90, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func saveAddressSettingsButton() -> some View {
Button {
hideKeyboard()
saveAddressSettings(settings, $savedSettings)
} label: {
Text("Save")
}
}
}
private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettings: Binding<AddressSettingsState>) {
Task {
do {
if let address = try await apiSetUserAddressSettings(settings.addressSettings) {
await MainActor.run {
ChatModel.shared.userAddress = address
savedSettings.wrappedValue = settings
}
}
} catch let error {
logger.error("apiSetUserAddressSettings error: \(responseError(error))")
}
}
}
private let simplexNameSaleStart = Calendar(identifier: .gregorian).date(from: DateComponents(timeZone: TimeZone(identifier: "UTC"), year: 2026, month: 12, day: 12, hour: 18))!
struct SetSimplexDomainView: View {
let title: LocalizedStringKey
let footer: LocalizedStringKey
let prompt: String
@State var simplexName: String
let broadcastWarning: String?
let save: (String?) async -> Bool
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var saving = false
@State private var original = ""
@State private var didSave = false
@State private var editing = false
@State private var timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow
@State private var saleTimer: Timer? = nil
@FocusState private var nameFocused: Bool
init(title: LocalizedStringKey, footer: LocalizedStringKey, prompt: String, simplexName: String, broadcastWarning: String? = nil, save: @escaping (String?) async -> Bool) {
self.title = title
self.footer = footer
self.prompt = prompt
self._simplexName = State(initialValue: simplexName)
self.broadcastWarning = broadcastWarning
self.save = save
self._original = State(initialValue: simplexName)
self._editing = State(initialValue: simplexName.isEmpty)
}
private var changed: Bool {
normalized(simplexName) != normalized(original)
}
private var isValid: Bool {
guard let d = normalized(simplexName) else { return true }
return isValidSimplexDomain(d)
}
var body: some View {
List {
Section {
if editing {
ZStack(alignment: .trailing) {
TextField(prompt, text: $simplexName)
.focused($nameFocused)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.padding(.trailing, isValid ? 0 : 20)
if !isValid {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
}
}
} else {
Button {
UIPasteboard.general.string = simplexName
} label: {
HStack {
Text(simplexName)
.foregroundColor(theme.colors.onBackground)
Spacer()
Image(systemName: "doc.on.doc")
.foregroundColor(theme.colors.secondary)
}
}
}
} header: {
Text(verbatim: "")
} footer: {
Text(footer).foregroundColor(theme.colors.secondary)
}
Section {
if editing {
Button {
openBrowserAlert(uri: "https://simplex.domains/#testing")
} label: {
HStack {
Text("How to register a test name")
Image(systemName: "arrow.up.right.circle")
}
}
Button {
if let w = broadcastWarning, changed {
showAlert(w, actions: {[
UIAlertAction(title: NSLocalizedString("Save", comment: "alert action"), style: .default) { _ in saveAndDismiss() },
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel)
]})
} else {
saveAndDismiss()
}
} label: {
Text("Save")
}
.disabled(saving || !isValid || !changed)
} else {
Button("Remove name") {
simplexName = ""
editing = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { nameFocused = true }
}
}
}
Section {
VStack(alignment: .leading, spacing: 4) {
Text(verbatim: saleCountdown(timeToSaleStart))
Text(timeToSaleStart > 0 ? "until you can register a SimpleX domain" : "Update the app to register a SimpleX domain")
.font(.caption)
.foregroundColor(theme.colors.secondary)
}
} header: {
Text("SimpleX name sale starts in")
.foregroundColor(theme.colors.secondary)
} footer: {
Text("Crowdfunding investors can reserve names before the sale starts: [simplex.domains](https://simplex.domains/)")
.foregroundColor(theme.colors.secondary)
.padding(.bottom)
}
}
.modifier(ThemedBackground(grouped: true))
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
.onAppear {
if editing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { nameFocused = true }
}
if timeToSaleStart > 0 {
saleTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { t in
timeToSaleStart = simplexNameSaleStart.timeIntervalSinceNow
if timeToSaleStart <= 0 { t.invalidate() }
}
}
}
.onDisappear {
saleTimer?.invalidate()
saleTimer = nil
if !didSave, !saving, changed, isValid {
let domain = normalized(simplexName)
let saveName = save
showAlert(
NSLocalizedString("Save SimpleX name?", comment: "alert title"),
message: broadcastWarning,
actions: {[
UIAlertAction(title: NSLocalizedString("Save", comment: "alert action"), style: .default) { _ in
Task { _ = await saveName(domain) }
},
UIAlertAction(title: NSLocalizedString("Don't save", comment: "alert action"), style: .cancel)
]}
)
}
}
}
private func saveAndDismiss() {
saving = true
Task {
let ok = await save(normalized(simplexName))
await MainActor.run {
saving = false
if ok {
didSave = true
dismiss()
}
}
}
}
private func normalized(_ s: String) -> String? {
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty
? nil
: addSimplexTLD((t.hasPrefix("@") || t.hasPrefix("#") ? String(t.dropFirst()) : t).lowercased())
}
private func saleCountdown(_ remaining: TimeInterval) -> String {
let total = max(0, Int(remaining))
let days = total / 86400
let dayStr = String.localizedStringWithFormat(
days == 1
? NSLocalizedString("%d day", comment: "time interval")
: NSLocalizedString("%d days", comment: "time interval"),
days
)
return dayStr + " " + String.localizedStringWithFormat(
NSLocalizedString("%02d hrs %02d min %02d sec", comment: "countdown"),
total / 3600 % 24, total / 60 % 60, total % 60
)
}
private func addSimplexTLD(_ d: String) -> String {
if d.contains(".") { d } else { "\(d).simplex" }
}
private func isValidSimplexDomain(_ s: String) -> Bool {
if s.utf8.count > 253 { return false }
let labels = s.split(separator: ".", omittingEmptySubsequences: false)
if labels.count < 2 { return false }
for label in labels {
if !isValidNameLabel(label) { return false }
}
return true
}
private func isValidNameLabel(_ label: Substring) -> Bool {
if label.isEmpty || label.utf8.count > 63 { return false }
return label.range(of: "^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$", options: .regularExpression) != nil
}
}
struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"))
return Group {
UserAddressView()
.environmentObject(chatModel)
UserAddressView()
.environmentObject(ChatModel())
}
}
}