ui changes

This commit is contained in:
Evgeny @ SimpleX Chat
2026-07-11 13:31:32 +00:00
parent e975c918c2
commit 21e116c702
10 changed files with 503 additions and 38 deletions
+57
View File
@@ -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)
@@ -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")
}
}
}
@@ -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<Bool, Never>) 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 {
@@ -410,7 +410,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
}
@Composable
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
fun ProfileNameField(name: MutableState<String>, 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<String>, 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)
}
}
@@ -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(
@@ -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
)
@@ -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()
}
@@ -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<Boolean> { 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
)
@@ -393,6 +393,8 @@
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
<string name="copy_verb">Copy</string>
<string name="remove_verb">Remove</string>
<string name="save_simplex_name_question">Save SimpleX name?</string>
<string name="save_verb">Save</string>
<string name="edit_verb">Edit</string>
<string name="info_menu">Info</string>
+157
View File
@@ -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 = <shown name> }` 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 25 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).