core, ui: show domain for business chat in CLI, test, improve UI (#7235)

* core: preserve domain during group handshake

* query plans

* ui changes

* remove unnecessary change

* improve ui

* name UI

* card layout

* fix footers, entry field

* error icon

* fix height

* fix layout

* fix layout

* remove unused string

* focus name field

* refactor, fix

* improve button

* refactor

* fix ios race

* core: add domain to channel /i output

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-07-12 09:41:05 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent 5d54362ca8
commit 414f4b6ce1
16 changed files with 608 additions and 146 deletions
+65 -21
View File
@@ -393,26 +393,7 @@ struct ChatInfoView: View {
.lineLimit(3)
.padding(.bottom, 2)
}
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.updateContact(ct)
contact = ct
}
return (ct.profile.contactDomainVerified, reason)
} catch {
logger.error("apiVerifyContactDomain: \(responseError(error))")
return nil
}
}
)
}
contactSimplexNameView(contact) { contact = $0 }
if let descr = cInfo.shortDescr?.trimmingCharacters(in: .whitespacesAndNewlines), descr != "" {
let r = markdownText(descr, textStyle: .subheadline, showSecrets: showSecrets, backgroundColor: theme.colors.background)
msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets, centered: true, smallFont: true)
@@ -1374,19 +1355,80 @@ private func deleteNotReadyContact(
))
}
@ViewBuilder func contactSimplexNameView(_ contact: Contact, verifiable: Bool = true, onUpdate: ((Contact) -> Void)? = nil) -> some View {
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)
onUpdate?(ct)
}
return (ct.profile.contactDomainVerified, reason)
} catch {
logger.error("apiVerifyContactDomain: \(responseError(error))")
return nil
}
},
verifiable: verifiable
)
}
}
@ViewBuilder func groupSimplexNameView(_ groupInfo: GroupInfo, verifiable: Bool = true, onUpdate: ((GroupInfo) -> Void)? = nil) -> some View {
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)
onUpdate?(gInfo)
}
return (gInfo.groupDomainVerified, reason)
} catch {
logger.error("apiVerifyGroupDomain: \(responseError(error))")
return nil
}
},
verifiable: verifiable
)
}
} else if let claim = groupInfo.businessChat?.businessDomain,
groupInfo.groupDomainVerified != nil || claim.proof != nil {
// A business presents as a contact, so the name retains its .simplex suffix; it cannot be re-verified.
SimplexNameView(
simplexName: "@\(claim.domain)",
verified: groupInfo.groupDomainVerified,
verify: { nil },
verifiable: false
)
}
}
struct SimplexNameView: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) var autoVerify = false
let simplexName: String
let verified: Bool?
let verify: () async -> (Bool?, String?)?
var verifiable: Bool = true
@State private var inFlight = false
@State private var showSpinner = false
var body: some View {
content
.padding(.bottom, 2)
.onAppear { if autoVerify && verified == nil { runVerify(manual: false) } }
.onAppear { if verifiable && autoVerify && verified == nil { runVerify(manual: false) } }
}
private var nameText: Text {
@@ -1415,6 +1457,8 @@ struct SimplexNameView: View {
UIPasteboard.general.string = simplexName
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
}
} else if !verifiable {
nameText
} else if verified == false {
HStack(alignment: .firstTextBaseline, spacing: 4) {
nameText
@@ -1037,6 +1037,15 @@ struct ChatView: View {
.padding(.horizontal)
}
switch chat.chatInfo {
case let .direct(contact):
contactSimplexNameView(contact, verifiable: false)
case let .group(groupInfo, _):
groupSimplexNameView(groupInfo, verifiable: false)
default:
EmptyView()
}
if let chatContext {
Text(chatContext)
.font(.callout)
@@ -112,7 +112,6 @@ struct GroupChatInfoView: View {
// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)
if groupInfo.isOwner && groupLink != nil {
channelLinkButton()
channelSimplexNameButton()
} else if let link = groupInfo.groupProfile.publicGroup?.groupLink {
SimpleXLinkQRCode(uri: link)
Button {
@@ -160,6 +159,16 @@ struct GroupChatInfoView: View {
}
}
if groupInfo.useRelays && groupInfo.isOwner && groupLink != nil {
Section {
channelSimplexNameButton()
} header: {
if groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName != nil {
Text("Channel SimpleX name").foregroundColor(theme.colors.secondary)
}
}
}
Section {
if groupInfo.isOwner && groupInfo.businessChat == nil {
editGroupButton()
@@ -332,38 +341,7 @@ struct GroupChatInfoView: View {
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
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.updateGroup(gInfo)
groupInfo = gInfo
}
return (gInfo.groupDomainVerified, reason)
} catch {
logger.error("apiVerifyGroupDomain: \(responseError(error))")
return nil
}
}
)
}
if let claim = groupInfo.businessChat?.businessDomain,
groupInfo.groupDomainVerified != nil || claim.proof != nil {
// 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: "@\(claim.domain)",
verified: groupInfo.groupDomainVerified,
verify: { nil }
)
}
groupSimplexNameView(groupInfo) { groupInfo = $0 }
if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage,
let url = URL(string: webPage) {
Link(destination: url) {
@@ -731,7 +709,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("Get SimpleX name (BETA)", systemImage: "number")
}
}
}
@@ -199,6 +199,7 @@ struct UserAddressView: View {
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)
@@ -210,7 +211,15 @@ struct UserAddressView: View {
}
)
} label: {
Label("Your SimpleX name", systemImage: "at")
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)
}
}
@@ -716,52 +725,162 @@ struct SetSimplexDomainView: View {
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
@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 {
TextField(prompt, text: $simplexName)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
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 {
Button {
saving = true
Task {
let ok = await save(normalized())
await MainActor.run {
saving = false
if ok { dismiss() }
}
if editing {
Button {
openBrowserAlert(uri: "https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md")
} label: {
Text("Register a test name")
}
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 }
}
} label: {
Text("Save")
}
.disabled(saving)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.large)
.onAppear {
if editing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { nameFocused = true }
}
}
.onDisappear {
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 normalized() -> String? {
let s = simplexName.trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty
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(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 }
return label.range(of: "^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$", options: .regularExpression) != nil
}
}
struct UserAddressView_Previews: PreviewProvider {
@@ -757,20 +757,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
)
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
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
}
}
)
}
ContactSimplexNameView(contact)
}
}
@@ -2343,6 +2343,12 @@ fun BoxScope.ChatItemsList(
)
}
when (chatInfo) {
is ChatInfo.Direct -> ContactSimplexNameView(chatInfo.contact, verifiable = false)
is ChatInfo.Group -> GroupSimplexNameView(chatInfo.groupInfo, verifiable = false)
else -> {}
}
val contextStr = chatContext()
if (contextStr != null) {
Text(
@@ -11,7 +11,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.ImageResource
import chat.simplex.common.model.SimplexNameInfo
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.helpers.*
@@ -28,6 +28,7 @@ import kotlinx.coroutines.*
fun SimplexNameView(
simplexName: String,
verified: Boolean?,
verifiable: Boolean = true,
verify: suspend () -> Pair<Boolean?, String?>?
) {
val scope = rememberCoroutineScope()
@@ -60,7 +61,7 @@ fun SimplexNameView(
}
LaunchedEffect(Unit) {
if (chatModel.controller.appPrefs.privacyVerifySimplexNames.get() && verified == null) runVerify(manual = false)
if (verifiable && chatModel.controller.appPrefs.privacyVerifySimplexNames.get() && verified == null) runVerify(manual = false)
}
val clipboard = LocalClipboardManager.current
@@ -82,6 +83,7 @@ fun SimplexNameView(
clipboard.setText(AnnotatedString(simplexName))
showToast(generalGetString(MR.strings.copied))
}
!verifiable -> Text(simplexName, style = nameStyle)
verified == false ->
SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, Color.Red) { runVerify(manual = true) }
else -> {
@@ -113,3 +115,55 @@ private fun SimplexNameWithIcon(name: String, style: TextStyle, icon: ImageResou
)
}
}
@Composable
fun ContactSimplexNameView(contact: Contact, verifiable: Boolean = true) {
val domain = contact.profile.contactDomain
if (domain != null && (contact.profile.contactDomainVerified != null || domain.proof != null)) {
SimplexNameView(
simplexName = "@${domain.domain}",
verified = contact.profile.contactDomainVerified,
verifiable = verifiable,
verify = {
val rhId = chatModel.remoteHostId()
chatModel.controller.apiVerifyContactDomain(rhId, contact.contactId)?.let { (ct, reason) ->
chatModel.chatsContext.updateContact(rhId, ct)
ct.profile.contactDomainVerified to reason
}
}
)
}
}
@Composable
fun GroupSimplexNameView(groupInfo: GroupInfo, verifiable: Boolean = true) {
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,
verifiable = verifiable,
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; it cannot be re-verified.
SimplexNameView(
simplexName = "@${businessClaim.domain}",
verified = groupInfo.groupDomainVerified,
verifiable = false,
verify = { null }
)
}
}
}
@@ -638,12 +638,6 @@ fun ModalData.GroupChatInfoLayout(
if (groupInfo.isOwner && groupLink != null) {
anyTopSectionRowShow = true
ChannelLinkButton(manageGroupLink)
SettingsActionItem(
painterResource(MR.images.ic_tag),
stringResource(MR.strings.simplex_name),
setSimplexName,
iconColor = MaterialTheme.colors.secondary
)
} else if (channelLink != null) {
anyTopSectionRowShow = true
ChannelLinkQRCodeSection(channelLink)
@@ -669,6 +663,18 @@ fun ModalData.GroupChatInfoLayout(
if (!groupInfo.isOwner && channelLink != null) {
SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect))
}
if (groupInfo.isOwner && groupLink != null) {
SectionDividerSpaced()
val channelDomain = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupDomainClaim?.shortName
SectionView(title = if (channelDomain != null) generalGetString(MR.strings.channel_simplex_name) else null) {
SettingsActionItem(
painterResource(MR.images.ic_tag),
channelDomain ?: generalGetString(MR.strings.get_simplex_name_beta),
setSimplexName,
iconColor = MaterialTheme.colors.secondary
)
}
}
} else {
SectionView {
if (groupInfo.canAddMembers && groupInfo.businessChat == null) {
@@ -973,32 +979,7 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
)
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
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
}
}
)
}
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 }
)
}
GroupSimplexNameView(groupInfo)
val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
if (webPage != null) {
val uriHandler = LocalUriHandler.current
@@ -107,12 +107,16 @@ fun TextEditor(
fun PlainTextEditor(
value: MutableState<String>,
placeholder: String? = null,
singleLine: Boolean = true
singleLine: Boolean = true,
contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 12.dp),
focusRequester: FocusRequester? = null
) {
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp),
modifier = Modifier.fillMaxWidth()
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.padding(contentPadding),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
singleLine = singleLine,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
@@ -3,74 +3,176 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewSpaceBetween
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.focus.FocusRequester
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
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.chat.item.openBrowserAlert
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
// 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,
broadcastWarning: String? = null,
save: suspend (String?) -> Boolean,
close: () -> Unit
) {
val name = rememberSaveable { mutableStateOf(simplexName) }
val saving = remember { mutableStateOf(false) }
val unchanged = name.value.trim() == simplexName.trim()
val editing = rememberSaveable { mutableStateOf(simplexName.isBlank()) }
val uriHandler = LocalUriHandler.current
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).lowercase())
else -> addSimplexTLD(t.lowercase())
}
}
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) {
if (registerBackgroundClose) 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),
text = broadcastWarning,
confirmText = generalGetString(MR.strings.save_verb),
onConfirm = { doSave(close) },
dismissText = generalGetString(MR.strings.exit_without_saving),
onDismiss = {
if (registerBackgroundClose) chatModel.centerPanelBackgroundClickHandler = null
close()
}
)
true
} else {
if (registerBackgroundClose) chatModel.centerPanelBackgroundClickHandler = null
close()
false
}
}
DisposableEffect(Unit) {
if (registerBackgroundClose) {
chatModel.centerPanelBackgroundClickHandler = {
onClose(close = { ModalManager.start.closeModals() })
}
}
onDispose {
if (registerBackgroundClose) chatModel.centerPanelBackgroundClickHandler = null
}
}
ModalView(close = { onClose(close) }, cardScreen = true) {
ColumnWithScrollBar {
AppBarTitle(title)
SectionView {
PlainTextEditor(name, placeholder)
if (editing.value) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
SectionItemViewSpaceBetween(click = { focusRequester.requestFocus() }) {
Box(Modifier.weight(1f)) {
PlainTextEditor(name, placeholder = placeholder, contentPadding = PaddingValues(), focusRequester = focusRequester)
}
if (!isValid) {
Icon(painterResource(MR.images.ic_error), null, tint = MaterialTheme.colors.error)
}
}
} else {
SectionItemViewSpaceBetween(click = {
clipboard.setText(AnnotatedString(name.value))
showToast(generalGetString(MR.strings.copied))
}) {
Text(name.value)
Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), tint = MaterialTheme.colors.secondary)
}
}
}
SectionTextFooter(footer)
SectionDividerSpaced()
SectionView {
SectionItemView(doSave, disabled = unchanged || saving.value) {
Text(
stringResource(MR.strings.save_verb),
color = if (unchanged || saving.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
if (editing.value) {
SectionItemView({ openBrowserAlert("https://github.com/simplex-chat/simplex-chat/blob/master/docs/guide/register-simplex-name.md", uriHandler) }) {
Text(stringResource(MR.strings.register_test_name), color = MaterialTheme.colors.primary)
}
SectionItemView({ if (broadcastWarning != null && !unchanged) AlertManager.shared.showAlertDialog(title = broadcastWarning, confirmText = generalGetString(MR.strings.save_verb), onConfirm = { doSave(close) }) else doSave(close) }, disabled = unchanged || saving.value || !isValid) {
Text(
stringResource(MR.strings.save_verb),
color = if (unchanged || saving.value || !isValid) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
} else {
SectionItemView({ name.value = ""; editing.value = true }) {
Text(stringResource(MR.strings.remove_name), color = MaterialTheme.colors.primary)
}
}
}
SectionBottomSpacer()
@@ -363,18 +363,20 @@ private fun UserAddressLayout(
}
SectionDividerSpaced()
SectionView {
val domain = user?.profile?.contactDomain?.domain
SectionView(title = if (domain != null) generalGetString(MR.strings.your_simplex_name) else null) {
SettingsActionItem(
painterResource(MR.images.ic_at),
stringResource(MR.strings.your_simplex_name),
if (domain != null) "$domain" else generalGetString(MR.strings.get_simplex_name_beta),
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,
broadcastWarning = generalGetString(MR.strings.profile_update_will_be_sent_to_contacts),
save = { simplexDomain ->
try {
val u = chatModel.controller.apiSetUserDomain(user?.remoteHostId, simplexDomain)
@@ -495,8 +495,8 @@ fun SocksProxySettings(
UseOnionHosts(onionHosts, rememberUpdatedState(networkUseSocksProxy && proxyAuthRandomUnsaved.value)) {
onionHosts.value = it
}
SectionTextFooter(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported))
}
SectionTextFooter(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported))
SectionDividerSpaced()
@@ -526,8 +526,8 @@ fun SocksProxySettings(
)
}
}
SectionTextFooter(proxyAuthFooter(usernameUnsaved.value.text, passwordUnsaved.value.text, proxyAuthModeUnsaved.value, sessionMode))
}
SectionTextFooter(proxyAuthFooter(usernameUnsaved.value.text, passwordUnsaved.value.text, proxyAuthModeUnsaved.value, sessionMode))
SectionDividerSpaced()
@@ -393,6 +393,11 @@
<string name="reply_verb">Reply</string>
<string name="share_verb">Share</string>
<string name="copy_verb">Copy</string>
<string name="save_simplex_name_question">Save SimpleX name?</string>
<string name="get_simplex_name_beta">Get SimpleX name (BETA)</string>
<string name="channel_simplex_name">Channel SimpleX name</string>
<string name="register_test_name">Register a test name</string>
<string name="remove_name">Remove 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).
+6 -1
View File
@@ -1833,13 +1833,18 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta
<> viewCustomData customData
viewGroupInfo :: GroupInfo -> [StyledString]
viewGroupInfo gInfo@GroupInfo {groupId, uiThemes, customData, groupSummary = GroupSummary {currentMembers, publicMemberCount}} =
viewGroupInfo gInfo@GroupInfo {groupId, businessChat, groupDomainVerified, groupProfile = GroupProfile {publicGroup}, uiThemes, customData, groupSummary = GroupSummary {currentMembers, publicMemberCount}} =
[ "group ID: " <> sShow groupId,
memberCountLine
]
<> domainLine
<> viewUITheme uiThemes
<> viewCustomData customData
where
-- a business presents as a contact (@-name); a public group/channel shows its #-name
domainLine = case businessChat of
Just bc -> simplexDomainLine NTContact (businessDomain bc) groupDomainVerified
Nothing -> simplexDomainLine NTPublicGroup (publicGroup >>= publicGroupAccess >>= groupDomainClaim) groupDomainVerified
memberCountLine
| useRelays' gInfo, Just count <- publicMemberCount = "subscribers: " <> sShow count
| otherwise = "current members: " <> sShow currentMembers
+5
View File
@@ -239,5 +239,10 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg ->
bob ##> "/_connect plan 1 @biz.simplex resolve=never"
bob <## "business address: known business #alice"
bob <## "use #alice <message> to send messages"
-- the business's verified domain survives the handshake and is shown in group info
bob ##> "/i #alice"
bob <## "group ID: 1"
bob <## "current members: 2"
bob <## "SimpleX name: @biz.simplex (verified)"
where
bizName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "biz" [])