diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 283157864d..cf13f83b65 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1037,6 +1037,63 @@ struct ChatView: View { .padding(.horizontal) } + switch chat.chatInfo { + case let .direct(contact): + if let domain = contact.profile.contactDomain, + contact.profile.contactDomainVerified != nil || domain.proof != nil { + SimplexNameView( + simplexName: "@\(domain.domain)", + verified: contact.profile.contactDomainVerified, + verify: { + do { + let (ct, reason) = try await apiVerifyContactDomain(contact.contactId) + await MainActor.run { + ChatModel.shared.updateContact(ct) + } + return (ct.profile.contactDomainVerified, reason) + } catch { + logger.error("apiVerifyContactDomain: \(responseError(error))") + return nil + } + } + ) + } + case let .group(groupInfo, _): + if groupInfo.businessChat == nil { + if let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess, + let domain = access.groupDomainClaim?.shortName, + groupInfo.groupDomainVerified != nil || access.groupDomainClaim?.proof != nil { + SimplexNameView( + simplexName: "#\(domain)", + verified: groupInfo.groupDomainVerified, + verify: { + do { + let (gInfo, reason) = try await apiVerifyGroupDomain(groupInfo.groupId) + await MainActor.run { + ChatModel.shared.updateGroup(gInfo) + } + return (gInfo.groupDomainVerified, reason) + } catch { + logger.error("apiVerifyGroupDomain: \(responseError(error))") + return nil + } + } + ) + } + } else { + if let claim = groupInfo.businessChat?.businessDomain, + groupInfo.groupDomainVerified != nil || claim.proof != nil { + SimplexNameView( + simplexName: "@\(claim.domain)", + verified: groupInfo.groupDomainVerified, + verify: { nil } + ) + } + } + default: + EmptyView() + } + if let chatContext { Text(chatContext) .font(.callout) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index e5370bcf32..86307c57e2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -731,7 +731,11 @@ struct GroupChatInfoView: View { } ) } label: { - Label("SimpleX name", systemImage: "number") + if let d = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName { + Label("#\(d)", systemImage: "number") + } else { + Label("SimpleX name", systemImage: "number") + } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 8dbadc9fd3..ae84ae8bb9 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -200,6 +200,24 @@ struct UserAddressView: View { prompt: "@yourname.testing", simplexName: simplexName, save: { simplexDomain in + if simplexDomain != chatModel.currentUser?.profile.contactDomain?.domain { + let confirmed = await withCheckedContinuation { (cont: CheckedContinuation) in + DispatchQueue.main.async { + showAlert( + NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert title"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Save", comment: "alert action"), style: .default) { _ in + cont.resume(returning: true) + }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert action"), style: .cancel) { _ in + cont.resume(returning: false) + } + ]} + ) + } + } + if !confirmed { return false } + } do { let u = try await apiSetUserDomain(simplexDomain) await MainActor.run { chatModel.updateUser(u) } @@ -210,7 +228,11 @@ struct UserAddressView: View { } ) } label: { - Label("Your SimpleX name", systemImage: "at") + if let d = chatModel.currentUser?.profile.contactDomain?.domain { + Label("@\(d)", systemImage: "at") + } else { + Label("Your SimpleX name", systemImage: "at") + } } } @@ -720,13 +742,31 @@ struct SetSimplexDomainView: View { @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme @State private var saving = false + @State private var original = "" + @State private var didSave = false + + 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 { - TextField(prompt, text: $simplexName) - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) + ZStack(alignment: .trailing) { + TextField(prompt, text: $simplexName) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .padding(.trailing, isValid ? 0 : 20) + if !isValid { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.red) + } + } } header: { Text(verbatim: "") } footer: { @@ -736,32 +776,86 @@ struct SetSimplexDomainView: View { Button { saving = true Task { - let ok = await save(normalized()) + let ok = await save(normalized(simplexName)) await MainActor.run { saving = false - if ok { dismiss() } + if ok { + didSave = true + dismiss() + } } } } label: { Text("Save") } - .disabled(saving) + .disabled(saving || !isValid || !changed) + if !original.isEmpty { + Button("Copy") { + UIPasteboard.general.string = simplexName + } + Button("Remove") { + simplexName = "" + } + } } } .navigationTitle(title) .navigationBarTitleDisplayMode(.large) + .onAppear { original = simplexName } + .onDisappear { + if !didSave, changed, isValid { + let domain = normalized(simplexName) + let saveName = save + showAlert( + NSLocalizedString("Save SimpleX name?", comment: "alert title"), + 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 normalized() -> String? { - let s = simplexName.trimmingCharacters(in: .whitespacesAndNewlines) - return s.isEmpty + private func normalized(_ s: String) -> String? { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + return t.isEmpty ? nil - : addSimplexTLD(s.hasPrefix("@") || s.hasPrefix("#") ? String(s.dropFirst()) : s) + : addSimplexTLD((t.hasPrefix("@") || t.hasPrefix("#") ? String(t.dropFirst()) : t).lowercased()) } 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 } + var prev: Unicode.Scalar? = nil + var isFirst = true + for u in label.unicodeScalars { + let v = u.value + if v == 45 { + if isFirst || prev?.value == 45 { return false } + } else if !((v >= 97 && v <= 122) || (v >= 65 && v <= 90) || (v >= 48 && v <= 57)) { + return false + } + prev = u + isFirst = false + } + return prev?.value != 45 + } } struct UserAddressView_Previews: PreviewProvider { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 3e4b8ce1db..0744145663 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -410,7 +410,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () } @Composable -fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) { +fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null, onInvalidTap: (() -> Unit)? = null) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } val strokeColor by remember { @@ -451,7 +451,7 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal leadingIcon = null, trailingIcon = if (!valid && placeholder != "") { { - IconButton({ showInvalidNameAlert(mkValidName(name.value), name) }, Modifier.size(20.dp)) { + IconButton((onInvalidTap ?: { showInvalidNameAlert(mkValidName(name.value), name) }), Modifier.size(20.dp)) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 4a3bfe4208..d646a6791d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -2343,6 +2343,59 @@ fun BoxScope.ChatItemsList( ) } + when (chatInfo) { + is ChatInfo.Direct -> { + val contact = chatInfo.contact + val domain = contact.profile.contactDomain + if (domain != null && (contact.profile.contactDomainVerified != null || domain.proof != null)) { + SimplexNameView( + simplexName = "@${domain.domain}", + verified = contact.profile.contactDomainVerified, + verify = { + val rhId = chatModel.remoteHostId() + chatModel.controller.apiVerifyContactDomain(rhId, contact.contactId)?.let { (ct, reason) -> + chatModel.chatsContext.updateContact(rhId, ct) + ct.profile.contactDomainVerified to reason + } + } + ) + } + } + is ChatInfo.Group -> { + val groupInfo = chatInfo.groupInfo + if (groupInfo.businessChat == null) { + val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess + val domain = access?.groupDomainClaim?.shortName + if (domain != null && (groupInfo.groupDomainVerified != null || access.groupDomainClaim?.proof != null)) { + SimplexNameView( + simplexName = "#${domain}", + verified = groupInfo.groupDomainVerified, + verify = { + val rhId = chatModel.remoteHostId() + chatModel.controller.apiVerifyGroupDomain(rhId, groupInfo.groupId)?.let { (gInfo, reason) -> + chatModel.chatsContext.updateGroup(rhId, gInfo) + gInfo.groupDomainVerified to reason + } + } + ) + } + } else { + val businessClaim = groupInfo.businessChat?.businessDomain + if (businessClaim != null && (groupInfo.groupDomainVerified != null || businessClaim.proof != null)) { + // A business presents as a contact, so the name retains its .simplex suffix. The tick comes from + // groupDomainVerified (set at connect); its domain proof is not received on the wire yet, so + // re-verification is not wired. + SimplexNameView( + simplexName = "@${businessClaim.domain}", + verified = groupInfo.groupDomainVerified, + verify = { null } + ) + } + } + } + else -> {} + } + val contextStr = chatContext() if (contextStr != null) { Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 4423f1dfbc..9d7c5c0ceb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -638,9 +638,10 @@ fun ModalData.GroupChatInfoLayout( if (groupInfo.isOwner && groupLink != null) { anyTopSectionRowShow = true ChannelLinkButton(manageGroupLink) + val channelDomain = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName SettingsActionItem( painterResource(MR.images.ic_tag), - stringResource(MR.strings.simplex_name), + if (channelDomain != null) "#$channelDomain" else stringResource(MR.strings.simplex_name), setSimplexName, iconColor = MaterialTheme.colors.secondary ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index c4cf0d1714..f0294ee4d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -5,73 +5,153 @@ 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.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +// Each dot-separated label is ASCII letters/digits with single internal hyphens (mirrors simplexmq SimplexName.hs nameLabelP). +private val simplexNameLabelRegex = Regex("[A-Za-z0-9]+(-[A-Za-z0-9]+)*") + // Set the user's own (prefix "@") or a channel's (prefix "#") SimpleX name. // The field is prefilled with the full prefixed name; `save` receives the encoded name (or null to // clear) and returns true on success (it shows its own error alert otherwise). +// `registerBackgroundClose` is set by the contact/start-panel call site so a desktop background click +// routes through the save-on-close prompt; the channel call site (opened via ModalManager.end) leaves it false. @Composable fun SetSimplexDomainView( title: String, footer: String, placeholder: String, simplexName: String, + registerBackgroundClose: Boolean = false, save: suspend (String?) -> Boolean, close: () -> Unit ) { val name = rememberSaveable { mutableStateOf(simplexName) } val saving = remember { mutableStateOf(false) } - val unchanged = name.value.trim() == simplexName.trim() + val clipboard = LocalClipboardManager.current fun addSimplexTLD(s: String): String { return if (s.contains(".")) s else "$s.simplex" } - fun normalized(): String? { - val s = name.value.trim() + fun normalized(s: String): String? { + val t = s.trim() return when { - s.isEmpty() -> null - s.startsWith("@") || s.startsWith("#") -> addSimplexTLD(s.substring(1)) - else -> addSimplexTLD(s) + t.isEmpty() -> null + t.startsWith("@") || t.startsWith("#") -> addSimplexTLD(t.substring(1)) + else -> addSimplexTLD(t) } } - val doSave: () -> Unit = { + // An empty field is valid (it means "remove the name"). Otherwise check the SimpleX-name grammar on + // the normalized value; A-Z is accepted because the core lowercases the name on accept. + fun isValidName(s: String): Boolean { + val n = normalized(s) ?: return true + if (n.length > 253) return false + val labels = n.split(".") + if (labels.size < 2) return false + return labels.all { it.length in 1..63 && simplexNameLabelRegex.matches(it) } + } + + val unchanged = normalized(name.value) == normalized(simplexName) + val isValid = isValidName(name.value) + + fun doSave(close: () -> Unit) { withBGApi { saving.value = true - val ok = try { save(normalized()) } catch (e: Exception) { + val ok = try { save(normalized(name.value)) } catch (e: Exception) { Log.e(TAG, "SetSimplexDomainView save: ${e.stackTraceToString()}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "") false } saving.value = false - if (ok) withContext(Dispatchers.Main) { close() } + if (ok) withContext(Dispatchers.Main) { + chatModel.centerPanelBackgroundClickHandler = null + close() + } } } - ModalView(close = close) { + // Reads name.value live so it stays correct when invoked from the background-click handler registered once. + // Returns true when it consumes the close (shows the prompt), false when it lets the close proceed. + fun onClose(close: () -> Unit): Boolean { + val valid = isValidName(name.value) + val changed = normalized(name.value) != normalized(simplexName) + return if (changed && valid) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.save_simplex_name_question), + confirmText = generalGetString(MR.strings.save_verb), + onConfirm = { doSave(close) }, + dismissText = generalGetString(MR.strings.exit_without_saving), + onDismiss = { + chatModel.centerPanelBackgroundClickHandler = null + close() + } + ) + true + } else { + chatModel.centerPanelBackgroundClickHandler = null + close() + false + } + } + + DisposableEffect(Unit) { + if (registerBackgroundClose) { + chatModel.centerPanelBackgroundClickHandler = { + onClose(close = { ModalManager.start.closeModals() }) + } + } + onDispose { + chatModel.centerPanelBackgroundClickHandler = null + } + } + + ModalView(close = { onClose(close) }) { ColumnWithScrollBar { AppBarTitle(title) SectionView { - PlainTextEditor(name, placeholder) + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + ProfileNameField( + name, + placeholder, + isValid = { isValidName(it) }, + onInvalidTap = { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.invalid_name)) } + ) + } } SectionTextFooter(footer) SectionDividerSpaced() SectionView { - SectionItemView(doSave, disabled = unchanged || saving.value) { + SectionItemView({ doSave(close) }, disabled = unchanged || saving.value || !isValid) { Text( stringResource(MR.strings.save_verb), - color = if (unchanged || saving.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + color = if (unchanged || saving.value || !isValid) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } + if (simplexName.isNotBlank()) { + SectionItemView({ + clipboard.setText(AnnotatedString(name.value)) + showToast(generalGetString(MR.strings.copied)) + }) { + Text(stringResource(MR.strings.copy_verb), color = MaterialTheme.colors.primary) + } + SectionItemView({ name.value = "" }) { + Text(stringResource(MR.strings.remove_verb), color = MaterialTheme.colors.primary) + } + } } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 7523ea40a0..2005ad3b81 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -35,7 +35,9 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlin.coroutines.resume @Composable fun UserAddressView( @@ -364,26 +366,41 @@ private fun UserAddressLayout( SectionDividerSpaced() SectionView { + val domain = user?.profile?.contactDomain?.domain SettingsActionItem( painterResource(MR.images.ic_at), - stringResource(MR.strings.your_simplex_name), + if (domain != null) "@$domain" else generalGetString(MR.strings.your_simplex_name), click = { ModalManager.start.showCustomModal { close -> - val domain = user?.profile?.contactDomain?.domain SetSimplexDomainView( title = generalGetString(MR.strings.set_simplex_name), footer = generalGetString(MR.strings.set_user_simplex_name_footer), placeholder = "@yourname.testing", simplexName = if (domain == null) "" else "@$domain", + registerBackgroundClose = true, save = { simplexDomain -> - try { - val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain) - withContext(Dispatchers.Main) { chatModel.updateUser(u) } - true - } catch (e: Exception) { - Log.e(TAG, "apiSetUserDomain: ${e.message}") - false - } + val changed = simplexDomain != domain + val proceed = if (changed) { + suspendCancellableCoroutine { cont -> + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.profile_update_will_be_sent_to_contacts), + confirmText = generalGetString(MR.strings.save_verb), + onConfirm = { if (cont.isActive) cont.resume(true) }, + onDismiss = { if (cont.isActive) cont.resume(false) }, + onDismissRequest = { if (cont.isActive) cont.resume(false) }, + ) + } + } else true + if (proceed) { + try { + val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain) + withContext(Dispatchers.Main) { chatModel.updateUser(u) } + true + } catch (e: Exception) { + Log.e(TAG, "apiSetUserDomain: ${e.message}") + false + } + } else false }, close = close ) 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 5cf764afd3..b958938dff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -393,6 +393,8 @@ Reply Share Copy + Remove + Save SimpleX name? Save Edit Info diff --git a/plans/2026-07-11-simplex-name-ux.md b/plans/2026-07-11-simplex-name-ux.md new file mode 100644 index 0000000000..be3ec7e153 --- /dev/null +++ b/plans/2026-07-11-simplex-name-ux.md @@ -0,0 +1,157 @@ +# SimpleX name — UX improvements plan + +Date: 2026-07-11. Scope: **client-only** (iOS SwiftUI + Kotlin/Compose for Android & desktop); no core / simplexmq change. +Adversarially verified against the code; fixes are merged inline below. + +The contact-address and channel name editors are the **same shared view** per platform +(iOS `SetSimplexDomainView` in `apps/ios/.../UserSettings/UserAddressView.swift:718`; Kotlin `SetSimplexDomainView` in +`apps/multiplatform/.../usersettings/SetSimplexNameView.kt:22`), so fixing the editor once covers both call sites. + +Reusable pieces: +- Badge: iOS `SimplexNameView` (`ChatInfoView.swift:1377`), Kotlin `SimplexNameView` (`SimplexNameView.kt:28`). +- Inline name-error pattern: iOS `CreateProfile.profileNameField` (`CreateProfile.swift:310-333` — red `exclamationmark.circle` + in the field when invalid + disabled action button); Kotlin `ProfileNameField` (`WelcomeView.kt:413`, warning `IconButton` + + `isValid` predicate). See Task 3 for the reuse caveats. +- Warnings: iOS `showAlert` free function; Kotlin `AlertManager.shared`. String "Profile update will be sent to your SimpleX + contacts" exists on both platforms. +- `apiSetUserDomain` broadcasts the updated profile to contacts (same update+notify path as `APIUpdateProfile`) — CONFIRMED. + +## Resolved design decisions (maintainer) +- **Banner name = interactive** (reuse `SimplexNameView`). The iOS banner observes `chat` (`@ObservedObject`), so a verify + updates the model and refreshes via the observed `chat` — no real caveat. +- **Remove button = clears the input field only**; no save, no confirm. Saving an empty field is what removes the name. +- **Validation = disable Save while invalid + the inline warning-icon pattern**, with a SimpleX-name predicate (below). +- **Save-on-close = prompt Save / Don't save, ONLY when the name is valid AND changed** (invalid or unchanged → silent close). +- **Row display**: actual name (`@name.simplex` / `#name`) when set; original label ("Your SimpleX name" for the address) when + unset. [address rows DONE] +- **Task 5 warning**: pre-save confirm using the existing string, only when the name actually changed; user/contact path only. +- Copy copies the shown prefixed name. Kotlin editor title stays "Set SimpleX name". + +## Task 1 — SimpleX name in the chat-start banner (contact / channel / business) — *safe to build* +Reuse `SimplexNameView`, switching on chat type. +- **iOS** `ChatView.swift` `struct ChatBannerView` (1004-1065): insert between the shortDescr block (~1038) and the + `chatContext` block (~1040). `switch chat.chatInfo`: + - `.direct(let contact)` → contact block (mirror `ChatInfoView.swift:396-414`). NOT verbatim: the banner has no + `@State contact`, so the verify closure uses `ChatModel.shared.updateContact(ct)` and drops `contact = ct`. + - `.group(let groupInfo, _)` → nested here (both need `groupInfo`): `businessChat == nil` → channel block + (`GroupChatInfoView.swift:335-354`). NOT verbatim: the banner binds an immutable `let groupInfo`, so — like the contact + case — the verify closure uses `ChatModel.shared.updateGroup(gInfo)` and drops the `groupInfo = gInfo` reassignment + (`GroupChatInfoView.swift:346`, which only compiles at the source because `groupInfo` there is an `@Binding`). Else business + block (`:356-366`, verify `{ nil }`). + - `default` → `EmptyView()` (keep the switch exhaustive). +- **Kotlin** `ChatView.kt` `ChatBannerView` (2227-2358): insert between the descr `MarkdownText` (~2344) and `chatContext()` + (~2346). `when (chatInfo)`: `Direct` → contact block (`ChatInfoView.kt:760-773`); `Group` + `businessChat==null` → channel + (`GroupChatInfoView.kt:976-990`); `Group` + `businessChat!=null` → business (`:991-1001`). `remoteHostId` + `chatModel` are in + scope, so the verify bodies copy verbatim. + +## Task 2 — row shows the name; Copy + Remove buttons in the editor +- **Row (address)**: DONE (iOS `UserAddressView.swift`; Kotlin `UserAddressView.kt`). +- **Row (channel)**: show `#name` when set, keep the existing label when unset. iOS `GroupChatInfoView.swift:734` (value from + `:712`, already `#`-prefixed). Kotlin `GroupChatInfoView.kt:641`; the value at `:183` is NOT `#`-prefixed — render `"#$name"`. +- **Editor buttons** (shared editor, shown only when the ORIGINAL prefill is non-empty). Each platform gates on its own original: + iOS reuses the `original` captured in Task 4; Kotlin has no `original` var — it gates on the `simplexName` param, which IS the + immutable original (Task 4, `SetSimplexNameView.kt:26`). + - iOS `SetSimplexDomainView` (`UserAddressView.swift:718`): in the Save `Section`, gate on `if !original.isEmpty` and add + `Button("Copy") { UIPasteboard.general.string = }` and `Button("Remove") { simplexName = "" }` — clears the + existing `@State var simplexName` (NOT a var named `name`); no save, no confirm. + - Kotlin `SetSimplexNameView.kt` (63-75): below the Save `SectionItemView`, `if (simplexName.isNotBlank())` add a Copy + `SectionItemView` (clipboard + copied toast) and a Remove `SectionItemView { name.value = "" }`. + +## Task 3 — validate; block Save on invalid +Add a SimpleX-name `isValid` predicate that **normalizes internally** (trim, strip a leading `@`/`#`, add `.simplex` via +`addSimplexTLD`) then checks the grammar (dot-separated ASCII `[a-zA-Z0-9]` labels + internal hyphens, ≤63 bytes/label, ≤253 +total, TLD label present), mirroring simplexmq `SimplexName.hs` `nameLabelP` — note `isNameLetter` (`SimplexName.hs:71`) accepts +`A-Z` as well as `a-z` and the parser lowercases on accept (`:90`), so `isValid` MUST accept uppercase (or, equivalently, +lowercase the input inside `normalized()` before the grammar check); a literal `[a-z0-9]` predicate would flag a valid uppercase +entry that the core accepts-and-lowercases, wrongly disabling Save. **`isValid` returns true for empty** (a cleared field is +valid — it means "remove"), so the warning icon doesn't flash on Remove. +- **iOS**: mirror `profileNameField` — red `exclamationmark.circle` in the field when invalid; `Save.disabled(saving || !isValid || unchanged)` + (also fixes iOS not disabling Save when unchanged/empty). +- **Kotlin**: the editor field is currently `PlainTextEditor(name, placeholder)` (`SetSimplexNameView.kt:64`), NOT `ProfileNameField`, + so the warning icon is not there yet and both options below change which field the editor renders. `ProfileNameField` is also NOT + a clean drop-in — its invalid-tap hardcodes `showInvalidNameAlert(mkValidName(name.value), name)` (`WelcomeView.kt:454`, the + display-name correction, wrong for `@name.simplex`) and passes the RAW `name.value` to `isValid` (`:473`). Either (a) REPLACE + `PlainTextEditor` with `ProfileNameField` — passing a NON-EMPTY `placeholder` (its `trailingIcon` is gated on `!valid && placeholder != ""`, + `WelcomeView.kt:452`, so an empty placeholder hides the warning) AND adding an invalid-tap/correction-callback param to + `ProfileNameField` so it takes the SimpleX `isValid` and correction instead of the hardcoded display-name one — or (b) keep + `PlainTextEditor` but wrap it (Row/Box) with a separately-rendered warning icon computed from the SimpleX `isValid`. Either way, + `Save.disabled = unchanged || saving.value || !isValid`. + +## Task 4 — save-on-close prompt (valid && changed), consistent across contact + channel — *largest fix* +The editor currently stores no baseline, so first **capture the original**, then compute `changed` by comparing the normalized +entered value against the normalized original. But the existing `normalized()` takes NO argument — iOS `normalized()` +(`UserAddressView.swift:759`) reads `self.simplexName`, Kotlin `normalized()` (`SetSimplexNameView.kt:38`) reads `name.value` — +so each normalizes only the entered value and there is no way to normalize the original. **Refactor `normalized()` to accept the +string as a parameter** (iOS `private func normalized(_ s: String) -> String?`; Kotlin `fun normalized(s: String): String?`), +update its one existing call in `doSave` to pass the entered value, then reuse it for both sides: +`changed = normalized(entered) != normalized(original)`. +- iOS: add `@State private var original` set in `.onAppear` to the prefill; also `@State private var didSave = false`. + Compute `changed = normalized(simplexName) != normalized(original)` (and `unchanged = !changed`). +- Kotlin: the `simplexName` param IS the immutable original; redefine `unchanged` (`SetSimplexNameView.kt:32`) as + `normalized(name.value) == normalized(simplexName)`, comparing normalized values rather than raw-trimmed, so it matches iOS, + and add `val changed = !unchanged` alongside it (Kotlin parallel to the iOS `changed`, referenced by the close-prompt gate below). + **Ordering caveat**: `unchanged` is a `val` at `:32`, but the local `fun normalized` and its helper `fun addSimplexTLD` + are declared *below* it (`:34` and `:38`), and Kotlin does NOT hoist local functions — a `:32` initializer referencing + `normalized` fails to compile ("unresolved reference: normalized"). So first MOVE the `addSimplexTLD` + `normalized` + function declarations above the `unchanged` line (keeping their order, `addSimplexTLD` before `normalized`), then + redefine `unchanged`. (iOS is unaffected: there `normalized` is a struct member function, visible regardless of textual + order.) +Prompt only when `changed && isValid` (on the contact path this Save action carries the Task 5 broadcast warning and saves with +the nested confirm suppressed — see Task 5 "Close-prompt interaction"): +- **iOS**: `.onDisappear { if !didSave && changed && isValid { showAlert("Save SimpleX name?", Save/Don't-save) } }`. + **CRITICAL**: set `didSave = true` on a successful Save — Save calls `dismiss()` (`UserAddressView.swift:746`) which fires + `.onDisappear` with the edited value still set, so without `didSave` the prompt double-fires right after saving (cf. + `UserProfile.swift:157` `getCurrentProfile()` which resets its baseline instead). +- **Kotlin**: `ModalView(close = { onClose(close) })`; `onClose` shows the prompt only when `changed && isValid`. The desktop + background-click bypasses `ModalView.close` (`ModalView.kt:41,61`; cf. `UserAddressView.kt:527`), so on the **contact / + start-panel path** the close must also route through `onClose` via `chatModel.centerPanelBackgroundClickHandler`. Because + `SetSimplexDomainView` (`SetSimplexNameView.kt:22-58`) is ONE shared function serving both call sites, it cannot infer which + path it is on — so give it the signal explicitly: add a `registerBackgroundClose: Boolean = false` param. The contact call + site (`UserAddressView.kt`) passes `true`; the channel call site (`GroupChatInfoView.kt:182`) leaves it `false`. In a + `LaunchedEffect(Unit)` the editor registers the handler (→ `onClose(close)`) ONLY when `registerBackgroundClose` is true. Do + NOT register it for the channel editor: that editor opens via `ModalManager.end` (`GroupChatInfoView.kt:182`) while the desktop + background-click overlay is gated on start-panel modals and closes only `ModalManager.start` (`App.kt:449-456`), so registering + there is both a dead no-op for the channel path (already covered by the app-bar back button = `ModalView.close`) AND, because + `centerPanelBackgroundClickHandler` is a single global slot on `chatModel` (`ChatModel.kt:238`), it would cross-wire a + start-panel background click to the channel editor's close logic. The handler MUST be cleared to `null` on EVERY close path — + the save path (`doSave`, `SetSimplexNameView.kt:47-58`), don't-save/revert, and direct close. Clearing to `null` is idempotent, + so the editor clears it unconditionally on close regardless of `registerBackgroundClose` (a safe no-op on the channel path, + which never set it); cf. the precedent (`UserAddressView.kt:507,514,518`). The existing `showUnsavedChangesAlert` + (`UserAddressView.kt:786`) hardcodes auto-accept strings — write a local prompt / new SimpleX-name strings, don't call it directly. +Same pass: unify the editor prefill form (contact prefills full `@name.simplex`, channel prefills short `#name` — pick one). + +## Task 5 — warn when saving a *contact* name (profile broadcast) +Gate in the caller's save closure (shared editor; the channel path is behaviorally untouched — no broadcast confirm — though its +closure signature changes with the `confirmBroadcast` flag below; channel uses `apiSetPublicGroupAccess`). The save +closure is `(String?) async -> Bool` (iOS) / `suspend (String?) -> Boolean` (Kotlin) and the confirm is callback-based, so +**bridge the dialog** so the closure awaits the user's choice before calling `apiSetUserDomain`: +- **iOS** `UserAddressView.swift ~202-210`: `withCheckedContinuation` around `showAlert`. **MUST use the actions-based + `showAlert` overload** (`ShareSheet.swift:61`), building BOTH the confirm and the cancel action with handlers that resume the + continuation **exactly once** — the default `showAlert(title:message:buttonTitle:buttonAction:)` overload's Cancel action + (`cancelAlertAction`, `ShareSheet.swift:130`) has NO handler, so tapping Cancel would never resume and the async save closure + would hang forever with `saving` stuck true. Gate on a **client-side compare** (entered name vs + `currentUser.profile.contactDomain?.domain`) — not the response — because `apiSetUserDomain` collapses both changed and NoChange + to `return user` (`SimpleXAPI.swift:1380`). Confirm text = the existing "Profile update will be sent to your SimpleX contacts". +- **Kotlin** `UserAddressView.kt ~378-387`: `suspendCancellableCoroutine` around `AlertManager.shared.showAlertDialog`; string + exists. Wire `onConfirm`, `onDismiss`, AND `onDismissRequest` (`AlertManager.kt:126-133`) to each resume the continuation + **exactly once** — otherwise a dismissal (tap-outside / back) leaves the suspended save coroutine hung. (The editor already + disables Save when unchanged, so the "only when changed" gate is largely covered here.) + +**Close-prompt interaction (Task 4 ↔ Task 5).** Tapping *Save* in the Task 4 "Save SimpleX name?" close prompt runs the same +contact save closure that carries this Task 5 broadcast confirm, so without a decision two dialogs stack (Task 4 prompt → +then Task 5 confirm). Decision: the close prompt already captured intent to save, so the nested Task 5 confirm is SUPPRESSED +when the save originates from the close prompt, and the warning is surfaced exactly once by giving the **contact-path** close +prompt the broadcast-warning text ("Profile update will be sent to your SimpleX contacts") as its message body (the plain +"Save SimpleX name?" wording stays for the channel path, which has no broadcast). Mechanism: thread a `confirmBroadcast` flag +(default `true`) into the save call — the normal in-editor Save leaves it `true` (shows this confirm), the close-prompt Save +passes `false`. Note this CHANGES the shared save-closure signature — Kotlin `suspend (String?) -> Boolean` → +`suspend (String?, Boolean) -> Boolean`, iOS `(String?) async -> Bool` → `(String?, Bool) async -> Bool` — so BOTH call sites' +closure literals must accept the flag or the code won't compile (mismatched closure types). The contact call site acts on it; +the channel call site (`GroupChatInfoView`) must be updated to accept and IGNORE it (its closure has no broadcast to suppress). +The contact vs channel distinction is the same signal Task 4 already carries (`registerBackgroundClose` on +Kotlin; pass the equivalent flag to iOS's `SetSimplexDomainView`), so the close prompt picks its message from it. + +## Status +- DONE: address row shows dynamic `@name.simplex` when set / "Your SimpleX name" when unset (iOS + Kotlin). +- Task 1 is safe to build as-is. Tasks 2–5 have the verification fixes merged above (Task 4 is the largest; do the Task-4 + `original`/`didSave`/close plumbing first since Tasks 2 and 3 reuse the `original` field and the disabled-Save state).