mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 02:54:34 +00:00
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:
co-authored by
Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent
5d54362ca8
commit
414f4b6ce1
@@ -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 {
|
||||
|
||||
+1
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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(
|
||||
|
||||
+56
-2
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-32
@@ -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
|
||||
|
||||
+6
-2
@@ -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),
|
||||
|
||||
+118
-16
@@ -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()
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
@@ -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 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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" [])
|
||||
|
||||
Reference in New Issue
Block a user