android, desktop, ios: connect via SimpleX name (#7068)

* android, desktop, ios: connect via SimpleX name

* android, desktop, ios: open known contact on name lookup; surface prepared contact

Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build.

* feat(names): UI names role + agent NAME error

Parity with the core names rework (#7045):

- Add `names` to ServerRoles (Android + iOS) and a per-operator
  "To resolve names" toggle under the SMP section (xftp has no names
  role; the shared ServerRoles field stays false there).
- Mirror the new agent error: NameErrorType + a NAME case on both
  AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so
  the new SMP/agent NAME errors decode instead of crashing the decoder.
- Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core)
  and repoint its "name resolution unavailable" alert to the agent
  NAME NO_SERVERS error, reusing the existing strings.

Android (multiplatform) compiles clean; iOS mirrors the same changes
(builds in Xcode).

* feat(names): UI warning when no server resolves names

Mirror core USWNoNamesServers: add the NoNamesServers variant to
UserServersWarning (Kotlin sealed class + Swift enum) and its
globalWarning / globalServersWarning branch, rendered by the existing
ServersWarningFooter / ServersWarningView. Matches the noChatRelays
warning exactly.

* fix(servers): show all validation errors and warnings, not just the first

globalServersError/Warning returned only the first entry, so a second
warning (e.g. no names servers behind no chat relays) or a second error
(e.g. no XFTP servers behind no SMP servers) was never displayed. Make
them return all entries (globalServersErrors/Warnings) and render one
footer row each, across the three combined-footer views. Per-protocol
SMP/XFTP footers are unchanged.

* docs(names): add SimpleX name UI plan

* feat(names): add name model fields + SimplexName helpers

* feat(names): verify + set-name API & responses

* docs(names): bump core sync to 5008b4e62

* feat(names): show name + verification on chat info

* feat(names): add Verify SimpleX names privacy toggle

* feat(names): add set-name screens (user + channel)

* update ui

* fix kotlin

* fix codable

* fix ios

* fix errors

* api in UI

* send name as string in protocol

* update simplexmq, capitalize

* verify that name is in profile for own and known contacts and channels as condition of name resolution

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
sh
2026-06-30 14:26:04 +04:00
committed by GitHub
parent ec8fe669c7
commit 9bd38c4aec
46 changed files with 1371 additions and 207 deletions
+26 -6
View File
@@ -84,6 +84,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiLeaveGroup(groupId: Int64)
case apiListMembers(groupId: Int64)
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
case apiSetPublicGroupAccess(groupId: Int64, access: PublicGroupAccess)
case apiCreateGroupLink(groupId: Int64, memberRole: GroupMemberRole)
case apiGroupLinkMemberRole(groupId: Int64, memberRole: GroupMemberRole)
case apiDeleteGroupLink(groupId: Int64)
@@ -155,6 +156,9 @@ enum ChatCommand: ChatCmdProtocol {
case apiAddMyAddressShortLink(userId: Int64)
case apiSetProfileAddress(userId: Int64, on: Bool)
case apiSetAddressSettings(userId: Int64, addressSettings: AddressSettings)
case apiSetUserName(userId: Int64, name: String?)
case apiVerifyContactName(contactId: Int64)
case apiVerifyPublicGroupName(groupId: Int64)
case apiAcceptContact(incognito: Bool, contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
// WebRTC calls
@@ -370,6 +374,10 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAddMyAddressShortLink(userId): return "/_short_link_address \(userId)"
case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))"
case let .apiSetAddressSettings(userId, addressSettings): return "/_address_settings \(userId) \(encodeJSON(addressSettings))"
case let .apiSetUserName(userId, name): return "/_set_name \(userId)" + (name.map { " " + $0 } ?? "")
case let .apiSetPublicGroupAccess(groupId, access): return "/_public group access #\(groupId) \(encodeJSON(access))"
case let .apiVerifyContactName(contactId): return "/_verify name @\(contactId)"
case let .apiVerifyPublicGroupName(groupId): return "/_verify name #\(groupId)"
case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))"
@@ -481,6 +489,7 @@ enum ChatCommand: ChatCmdProtocol {
case .apiLeaveGroup: return "apiLeaveGroup"
case .apiListMembers: return "apiListMembers"
case .apiUpdateGroupProfile: return "apiUpdateGroupProfile"
case .apiSetPublicGroupAccess: return "apiSetPublicGroupAccess"
case .apiCreateGroupLink: return "apiCreateGroupLink"
case .apiGroupLinkMemberRole: return "apiGroupLinkMemberRole"
case .apiDeleteGroupLink: return "apiDeleteGroupLink"
@@ -551,6 +560,9 @@ enum ChatCommand: ChatCmdProtocol {
case .apiAddMyAddressShortLink: return "apiAddMyAddressShortLink"
case .apiSetProfileAddress: return "apiSetProfileAddress"
case .apiSetAddressSettings: return "apiSetAddressSettings"
case .apiSetUserName: return "apiSetUserName"
case .apiVerifyContactName: return "apiVerifyContactName"
case .apiVerifyPublicGroupName: return "apiVerifyPublicGroupName"
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiSendCallInvitation: return "apiSendCallInvitation"
@@ -960,6 +972,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole)
case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool)
case groupUpdated(user: UserRef, toGroup: GroupInfo)
case contactNameVerified(user: UserRef, contact: Contact, verificationFailure: String?)
case groupNameVerified(user: UserRef, groupInfo: GroupInfo, verificationFailure: String?)
case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink)
case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo)
@@ -1015,6 +1029,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case .membersRoleUser: "membersRoleUser"
case .membersBlockedForAllUser: "membersBlockedForAllUser"
case .groupUpdated: "groupUpdated"
case .contactNameVerified: "contactNameVerified"
case .groupNameVerified: "groupNameVerified"
case .groupLinkCreated: "groupLinkCreated"
case .groupLink: "groupLink"
case .groupLinkDeleted: "groupLinkDeleted"
@@ -1066,6 +1082,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)")
case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)")
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
case let .contactNameVerified(u, contact, verificationFailure): return withUser(u, "contact: \(contact)\nverificationFailure: \(verificationFailure ?? "ok")")
case let .groupNameVerified(u, groupInfo, verificationFailure): return withUser(u, "groupInfo: \(groupInfo)\nverificationFailure: \(verificationFailure ?? "ok")")
case let .groupLinkCreated(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)")
case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo))
@@ -1383,7 +1401,7 @@ enum InvitationLinkPlan: Decodable, Hashable {
}
enum ContactAddressPlan: Decodable, Hashable {
case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)
case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, verifiedName: SimplexNameInfo?)
case ownLink
case connectingConfirmReconnect
case connectingProhibit(contact: Contact)
@@ -1398,7 +1416,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable {
}
enum GroupLinkPlan: Decodable, Hashable {
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?)
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, verifiedName: SimplexNameInfo?)
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
@@ -1766,14 +1784,15 @@ struct ServerOperator: Identifiable, Equatable, Codable {
serverDomains: ["simplex.im"],
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
enabled: true,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
smpRoles: ServerRoles(storage: true, proxy: true, names: true),
xftpRoles: ServerRoles(storage: true, proxy: true, names: false)
)
}
struct ServerRoles: Equatable, Codable {
var storage: Bool
var proxy: Bool
var names: Bool
}
struct UserOperatorServers: Identifiable, Equatable, Codable {
@@ -1800,8 +1819,8 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
serverDomains: [],
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
enabled: false,
smpRoles: ServerRoles(storage: true, proxy: true),
xftpRoles: ServerRoles(storage: true, proxy: true)
smpRoles: ServerRoles(storage: true, proxy: true, names: true),
xftpRoles: ServerRoles(storage: true, proxy: true, names: false)
)
}
set { `operator` = newValue }
@@ -1824,6 +1843,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
public enum UserServersWarning: Decodable {
case noChatRelays(user: UserRef?)
case noNamesServers(user: UserRef?)
}
enum UserServersError: Decodable {
+47
View File
@@ -1083,6 +1083,24 @@ private func apiConnectResponseAlert<R>(_ r: APIResult<R>) -> Alert {
title: "Unsupported connection link",
message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link."
)
case let .error(.simplexName(name, err)):
switch err {
case .noValidLink:
mkAlert(
title: "Cannot reconnect via name",
message: "This SimpleX name is known but has no saved link to reconnect via."
)
case .unknownName:
mkAlert(
title: name.nameType == .contact ? "Contact name not found" : "Channel name not found",
message: "There is no contact or group registered with this SimpleX name."
)
}
case .errorAgent(.NO_NAME_SERVERS):
mkAlert(
title: "Name resolution unavailable",
message: "None of your SMP servers support resolving SimpleX names. Add a server that does, or use a connection link."
)
case .errorAgent(.SMP(_, .AUTH)):
mkAlert(
title: "Connection error (AUTH)",
@@ -1329,6 +1347,29 @@ func apiSetProfileAddress(on: Bool) async throws -> User? {
}
}
// name is the encoded SimplexName (e.g. "@alice.simplex"); nil clears it
func apiSetUserName(_ name: String?) async throws -> User {
let userId = try currentUserId("apiSetUserName")
let r: ChatResponse1 = try await chatSendCmd(.apiSetUserName(userId: userId, name: name))
switch r {
case let .userProfileUpdated(user, _, _, _): return user
case let .userProfileNoChange(user): return user
default: throw r.unexpected
}
}
func apiVerifyContactName(_ contactId: Int64) async throws -> (Contact, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyContactName(contactId: contactId))
if case let .contactNameVerified(_, contact, verificationFailure) = r { return (contact, verificationFailure) }
throw r.unexpected
}
func apiVerifyPublicGroupName(_ groupId: Int64) async throws -> (GroupInfo, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyPublicGroupName(groupId: groupId))
if case let .groupNameVerified(_, groupInfo, verificationFailure) = r { return (groupInfo, verificationFailure) }
throw r.unexpected
}
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact }
@@ -1995,6 +2036,12 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws
throw r.unexpected
}
func apiSetPublicGroupAccess(_ groupId: Int64, access: PublicGroupAccess) async throws -> GroupInfo {
let r: ChatResponse2 = try await chatSendCmd(.apiSetPublicGroupAccess(groupId: groupId, access: access))
if case let .groupUpdated(_, toGroup) = r { return toGroup }
throw r.unexpected
}
func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink }
@@ -392,6 +392,26 @@ struct ChatInfoView: View {
.lineLimit(3)
.padding(.bottom, 2)
}
if let claim = contact.profile.simplexName, claim.proof != nil {
SimplexNameView(
name: claim.shortName,
verification: contact.profile.contactDomainVerification,
autoVerify: UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES),
verify: {
do {
let (ct, reason) = try await apiVerifyContactName(contact.contactId)
await MainActor.run {
chatModel.updateContact(ct)
contact = ct
}
return (ct.profile.contactDomainVerification, reason)
} catch {
logger.error("apiVerifyContactName: \(responseError(error))")
return nil
}
}
)
}
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)
@@ -1361,6 +1381,70 @@ private func deleteNotReadyContact(
))
}
struct SimplexNameView: View {
let name: String
let verification: Bool?
let autoVerify: Bool
let verify: () async -> (Bool?, String?)?
@EnvironmentObject var theme: AppTheme
@State private var inFlight = false
@State private var showSpinner = false
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
HStack(spacing: 6) {
Text(name)
.font(verification == true ? .subheadline : .system(.subheadline, design: .monospaced))
.foregroundColor(verification == true ? theme.colors.primary : theme.colors.secondary)
indicator()
}
.padding(.bottom, 2)
.onAppear { if autoVerify && verification == nil { runVerify(manual: false) } }
.alert(isPresented: $showAlert) {
Alert(title: Text("SimpleX name not verified"), message: Text(alertMessage))
}
}
@ViewBuilder private func indicator() -> some View {
if showSpinner {
ProgressView()
} else if verification == true {
Image(systemName: "checkmark")
} else if verification == false {
Image(systemName: "xmark")
.foregroundColor(.red)
.onTapGesture { runVerify(manual: true) }
} else {
Button { runVerify(manual: true) } label: {
Text("Verify name").font(.subheadline).foregroundColor(theme.colors.primary)
}
}
}
private func runVerify(manual: Bool) {
if inFlight { return }
inFlight = true
// delay the spinner so a fast result on appear doesn't flash it
Task {
try? await Task.sleep(nanoseconds: 300_000000)
await MainActor.run { if inFlight { showSpinner = true } }
}
Task {
let res = await verify()
await MainActor.run {
inFlight = false
showSpinner = false
// show the reason on a manual run, or on an inconclusive auto run (state stayed nil)
if let (newV, reason) = res, let reason, manual || newV == nil {
alertMessage = reason
showAlert = true
}
}
}
}
}
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
@@ -214,8 +214,11 @@ private func handleTextTaps(
var simplex: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
showUnsupportedNameAlert(nameInfo)
if attrs[nameAttrKey] is SimplexNameInfo {
// Route the tapped name through the same connect flow as a link;
// planAndConnect resolves it on the core (name target). This runs
// in a free function with no view context, so use the global theme.
planAndConnect(s.attributedSubstring(from: range).string, theme: AppTheme.shared, dismiss: false)
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
@@ -148,7 +148,7 @@ struct ChannelWebAccessView: View {
let existingAccess = pg.publicGroupAccess
pg.publicGroupAccess = PublicGroupAccess(
groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage,
groupDomain: existingAccess?.groupDomain,
simplexName: existingAccess?.simplexName,
domainWebPage: existingAccess?.domainWebPage ?? false,
allowEmbedding: allowEmbedding
)
@@ -247,6 +247,32 @@ struct GroupChatInfoView: View {
if groupInfo.useRelays && groupInfo.isOwner {
Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) {
channelWebAccessButton()
if groupInfo.groupProfile.publicGroup?.publicGroupAccess != nil {
NavigationLink {
SetSimplexNameView(
titleKey: "Set SimpleX name",
footer: "Set a SimpleX name so people can find this channel as #name. The name must be registered to this channel's address.",
prefix: "#",
nameText: groupInfo.groupProfile.publicGroup?.publicGroupAccess?.simplexName?.shortName ?? "",
save: { name in
do {
var access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?? PublicGroupAccess()
access.simplexName = name.map { SimplexNameClaim(name: $0) }
let gInfo = try await apiSetPublicGroupAccess(groupInfo.groupId, access: access)
await MainActor.run {
chatModel.updateGroup(gInfo)
groupInfo = gInfo
}
return nil
} catch {
return responseError(error)
}
}
)
} label: {
Label("Set SimpleX name", systemImage: "checkmark.shield")
}
}
}
}
@@ -331,6 +357,28 @@ struct GroupChatInfoView: View {
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess,
let groupName = access.simplexName?.shortName,
access.simplexName?.proof != nil {
SimplexNameView(
name: groupName,
verification: groupInfo.groupDomainVerification,
autoVerify: UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES),
verify: {
do {
let (gInfo, reason) = try await apiVerifyPublicGroupName(groupInfo.groupId)
await MainActor.run {
chatModel.updateGroup(gInfo)
groupInfo = gInfo
}
return (gInfo.groupDomainVerification, reason)
} catch {
logger.error("apiVerifyPublicGroupName: \(responseError(error))")
return nil
}
}
)
}
if let webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage,
let url = URL(string: webPage) {
Link(destination: url) {
@@ -22,7 +22,7 @@ enum UserPickerSheet: Identifiable {
var navigationTitle: LocalizedStringKey {
switch self {
case .address: "SimpleX address"
case .address: "SimpleX address and name"
case .chatPreferences: "Your preferences"
case .chatProfiles: "Your chat profiles"
case .currentProfile: "Your current profile"
@@ -683,8 +683,19 @@ struct ChatListSearchBar: View {
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case let .name(text, _):
// A name lookup means "take me to this contact": open it (visible prompt),
// unlike a pasted link in search which filters the list so no filterKnownContact.
searchFocussed = false
planAndConnect(
text,
theme: theme,
dismiss: false,
cleanup: {
searchText = ""
searchFocussed = false
}
)
case .none:
if t != "" {
searchFocussed = true
@@ -389,8 +389,19 @@ struct ContactsListSearchBar: View {
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
connect(text)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case let .name(text, _):
// A name lookup means "take me to this contact": open it (visible prompt),
// unlike a pasted link in search which filters the list so no filterKnownContact.
searchFocussed = false
planAndConnect(
text,
theme: theme,
dismiss: true,
cleanup: {
searchText = ""
searchFocussed = false
}
)
case .none:
if t != "" {
searchFocussed = true
+23 -27
View File
@@ -669,8 +669,9 @@ private struct ConnectView: View {
case let .link(text, _, _):
pastedLink = text
connect(pastedLink)
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
case let .name(text, _):
pastedLink = text
connect(pastedLink)
case .none:
alert = .newChatSomeAlert(alert: SomeAlert(
alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."),
@@ -869,7 +870,7 @@ func strIsSimplexLink(_ str: String) -> Bool {
enum ConnectTarget {
case link(text: String, linkType: SimplexLinkType, linkText: String)
case name(SimplexNameInfo)
case name(text: String, nameInfo: SimplexNameInfo)
}
func strConnectTarget(_ str: String) -> ConnectTarget? {
@@ -878,28 +879,14 @@ func strConnectTarget(_ str: String) -> ConnectTarget? {
return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format {
.link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts))
} else if links.isEmpty,
case let .simplexName(nameInfo) = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } })?.format {
.name(nameInfo)
let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }),
case let .simplexName(nameInfo) = nameFt.format {
.name(text: nameFt.text, nameInfo: nameInfo)
} else {
nil
}
}
func showUnsupportedNameAlert(_ nameInfo: SimplexNameInfo) {
let upgrade = " " + NSLocalizedString("Please upgrade the app.", comment: "alert message")
if nameInfo.nameType == .contact {
showAlert(
NSLocalizedString("Unsupported contact name", comment: "alert title"),
message: NSLocalizedString("Connecting via contact name requires a newer app version.", comment: "alert message") + upgrade
)
} else {
showAlert(
NSLocalizedString("Unsupported channel name", comment: "alert title"),
message: NSLocalizedString("Connecting via channel name requires a newer app version.", comment: "alert message") + upgrade
)
}
}
struct IncognitoToggle: View {
@EnvironmentObject var theme: AppTheme
@Binding var incognitoEnabled: Bool
@@ -1319,10 +1306,6 @@ func planAndConnect(
filterKnownGroup: ((GroupInfo) -> Void)? = nil
) {
switch strConnectTarget(shortOrFullLink) {
case let .name(nameInfo):
showUnsupportedNameAlert(nameInfo)
cleanup?()
return
case let .link(_, linkType, _):
if linkType == .relay {
showAlert(
@@ -1332,7 +1315,9 @@ func planAndConnect(
cleanup?()
return
}
case .none: break
// A SimplexName falls through to apiConnectPlan, which resolves it on the
// core (the /_connect plan command accepts a name target, not only a link).
case .name, .none: break
}
ConnectProgressManager.shared.cancelConnectProgress()
let inProgress = BoxedValue(true)
@@ -1416,7 +1401,7 @@ func planAndConnect(
}
case let .contactAddress(cap):
switch cap {
case let .ok(contactSLinkData_, ownerVerification):
case let .ok(contactSLinkData_, ownerVerification, _):
if let contactSLinkData = contactSLinkData_ {
logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
await MainActor.run {
@@ -1478,6 +1463,12 @@ func planAndConnect(
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known")
await MainActor.run {
// A name-resolved contact is prepared in the store but not yet in the
// chat list (link-prepared chats arrive via NewPreparedChat). Surface it
// so it's visible and openable; no-op if already present.
if ChatModel.shared.getContactChat(contact.contactId) == nil {
ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact)))
}
if let f = filterKnownContact {
f(contact)
} else {
@@ -1496,7 +1487,7 @@ func planAndConnect(
}
case let .groupLink(glp):
switch glp {
case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification):
case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, _):
if let groupSLinkData = groupSLinkData_ {
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
await MainActor.run {
@@ -1557,6 +1548,11 @@ func planAndConnect(
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known")
await MainActor.run {
// Same as .contactAddress .known: surface a name-resolved (prepared)
// group in the chat list so it's visible and openable.
if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil {
ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil)))
}
if let f = filterKnownGroup {
f(groupInfo)
} else {
@@ -111,13 +111,16 @@ struct NetworkAndServers: View {
Button("Save servers", action: { saveServers($ss.servers.currUserServers, $ss.servers.userServers) })
.disabled(!serversCanBeSaved(ss.servers.currUserServers, ss.servers.userServers, ss.servers.serverErrors))
} footer: {
if let errStr = globalServersError(ss.servers.serverErrors) {
ServersErrorView(errStr: errStr)
let errs = globalServersErrors(ss.servers.serverErrors)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !ss.servers.serverErrors.isEmpty {
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
}
if let warnStr = globalServersWarning(ss.servers.serverWarnings) {
ServersWarningView(warnStr: warnStr)
ForEach(globalServersWarnings(ss.servers.serverWarnings), id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
}
@@ -397,17 +400,12 @@ struct ServersWarningView: View {
}
}
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
for err in serverErrors {
if let errStr = err.globalError {
return errStr
}
}
return nil
func globalServersErrors(_ serverErrors: [UserServersError]) -> [String] {
serverErrors.compactMap { $0.globalError }
}
func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
for warn in serverWarnings {
func globalServersWarnings(_ serverWarnings: [UserServersWarning]) -> [String] {
serverWarnings.map { warn in
switch warn {
case let .noChatRelays(user):
let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning")
@@ -417,9 +415,16 @@ func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
user.localDisplayName
) + " " + text
} else { return text }
case let .noNamesServers(user):
let text = NSLocalizedString("No servers to resolve names.", comment: "servers warning")
if let user = user {
return String.localizedStringWithFormat(
NSLocalizedString("For chat profile %@:", comment: "servers warning"),
user.localDisplayName
) + " " + text
} else { return text }
}
}
return nil
}
func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> {
@@ -52,10 +52,16 @@ struct OperatorView: View {
Text("Operator")
.foregroundColor(theme.colors.secondary)
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
let errs = globalServersErrors(serverErrors)
let warns = globalServersWarnings(serverWarnings)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !warns.isEmpty {
ForEach(warns, id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
} else {
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
case let .accepted(acceptedAt, _):
@@ -105,6 +111,10 @@ struct OperatorView: View {
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
validateServers_($userServers, $serverErrors, $serverWarnings)
}
Toggle("To resolve names", isOn: $userServers[operatorIndex].operator_.smpRoles.names)
.onChange(of: userServers[operatorIndex].operator_.smpRoles.names) { _ in
validateServers_($userServers, $serverErrors, $serverWarnings)
}
} header: {
Text("Use for messages")
.foregroundColor(theme.colors.secondary)
@@ -169,10 +169,16 @@ struct YourServersView: View {
.hidden()
}
} footer: {
if let errStr = globalServersError(serverErrors) {
ServersErrorView(errStr: errStr)
} else if let warnStr = globalServersWarning(serverWarnings) {
ServersWarningView(warnStr: warnStr)
let errs = globalServersErrors(serverErrors)
let warns = globalServersWarnings(serverWarnings)
if !errs.isEmpty {
ForEach(errs, id: \.self) { err in
ServersErrorView(errStr: err)
}
} else if !warns.isEmpty {
ForEach(warns, id: \.self) { warn in
ServersWarningView(warnStr: warn)
}
}
}
@@ -16,6 +16,7 @@ struct PrivacySettings: View {
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) private var verifySimplexNames = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@@ -81,6 +82,9 @@ struct PrivacySettings: View {
settingsRow("link", color: theme.colors.secondary) {
Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
}
settingsRow("checkmark.shield", color: theme.colors.secondary) {
Toggle("Verify SimpleX names", isOn: $verifySimplexNames)
}
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
@@ -32,6 +32,7 @@ let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_D
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES = "privacyVerifySimplexNames"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
@@ -99,6 +100,7 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_LINK_PREVIEWS: true,
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES: true,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
@@ -191,6 +191,28 @@ struct UserAddressView: View {
}
}
Section {
NavigationLink {
SetSimplexNameView(
titleKey: "Set SimpleX name",
footer: "Set a SimpleX name so people can connect to you using @yourname instead of a link. The name must already be registered to your address.",
prefix: "@",
nameText: chatModel.currentUser?.profile.simplexName?.shortName ?? "",
save: { name in
do {
let u = try await apiSetUserName(name)
await MainActor.run { chatModel.updateUser(u) }
return nil
} catch {
return responseError(error)
}
}
)
} label: {
Label("Set SimpleX name", systemImage: "checkmark.shield")
}
}
Section {
createOneTimeLinkButton()
} header: {
@@ -688,6 +710,66 @@ private func saveAddressSettings(_ settings: AddressSettingsState, _ savedSettin
}
}
// 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 nil to
// clear) and returns a failure message (nil on success). The prefix is normalised on save.
struct SetSimplexNameView: View {
let titleKey: LocalizedStringKey
let footer: LocalizedStringKey
let prefix: String
@State var nameText: String
let save: (String?) async -> String?
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var saving = false
@State private var showAlert = false
@State private var alertMessage = ""
var body: some View {
List {
Section {
TextField(prefix + "name.simplex", text: $nameText)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
} footer: {
Text(footer).foregroundColor(theme.colors.secondary)
}
Section {
Button {
saving = true
Task {
let err = await save(normalized())
await MainActor.run {
saving = false
if let err {
alertMessage = err
showAlert = true
} else {
dismiss()
}
}
}
} label: {
Text("Save")
}
.disabled(saving)
}
}
.navigationTitle(titleKey)
.alert(isPresented: $showAlert) {
Alert(title: Text("Error saving SimpleX name"), message: Text(alertMessage))
}
}
// ensure the correct type prefix so a contact name is not parsed as a group (or vice versa)
private func normalized() -> String? {
let s = nameText.trimmingCharacters(in: .whitespacesAndNewlines)
if s.isEmpty { return nil }
if s.hasPrefix("@") || s.hasPrefix("#") { return prefix + s.dropFirst() }
return prefix + s
}
}
struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
+9
View File
@@ -741,6 +741,7 @@ public enum ChatErrorType: Decodable, Hashable {
case chatNotStopped
case chatStoreChanged
case invalidConnReq
case simplexName(simplexName: SimplexNameInfo, simplexNameError: SimplexNameError)
case unsupportedConnReq
case invalidChatMessage(connection: Connection, message: String)
case connReqMessageProhibited
@@ -896,6 +897,13 @@ public enum AgentErrorType: Decodable, Hashable {
case INTERNAL(internalErr: String)
case CRITICAL(offerRestart: Bool, criticalErr: String)
case INACTIVE
case NO_NAME_SERVERS
}
public enum NameErrorType: Decodable, Hashable {
case NO_RESOLVER
case NOT_FOUND
case RESOLVER(resolverErr: String)
}
public enum CommandErrorType: Decodable, Hashable {
@@ -937,6 +945,7 @@ public enum ProtocolErrorType: Decodable, Hashable {
case LARGE_MSG
case EXPIRED
case INTERNAL
case NAME(nameErr: NameErrorType)
}
public enum ProxyError: Decodable, Hashable {
+76 -9
View File
@@ -161,7 +161,9 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
preferences: Preferences? = nil,
peerType: ChatPeerType? = nil,
localBadge: LocalBadge? = nil,
localAlias: String
localAlias: String,
simplexName: SimplexNameClaim? = nil,
contactDomainVerification: Bool? = nil
) {
self.profileId = profileId
self.displayName = displayName
@@ -173,6 +175,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
self.peerType = peerType
self.localBadge = localBadge
self.localAlias = localAlias
self.simplexName = simplexName
self.contactDomainVerification = contactDomainVerification
}
public var profileId: Int64
@@ -185,6 +189,8 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
public var peerType: ChatPeerType?
public var localBadge: LocalBadge?
public var localAlias: String
public var simplexName: SimplexNameClaim?
public var contactDomainVerification: Bool?
var profileViewName: String {
localAlias == ""
@@ -2507,7 +2513,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var groupId: Int64
public var useRelays: Bool
public var relayOwnStatus: RelayStatus? = nil
var localDisplayName: GroupName
public var localDisplayName: GroupName
public var groupProfile: GroupProfile
public var businessChat: BusinessChatInfo?
public var fullGroupPreferences: FullGroupPreferences
@@ -2534,6 +2540,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var chatTags: [Int64]
public var chatItemTTL: Int64?
public var localAlias: String
public var groupDomainVerification: Bool?
public var isOwner: Bool {
return membership.memberRole == .owner && membership.memberCurrent
@@ -2614,19 +2621,37 @@ public enum GroupType: Codable, Hashable {
}
public struct PublicGroupAccess: Codable, Hashable {
public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) {
public init(groupWebPage: String? = nil, simplexName: SimplexNameClaim? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) {
self.groupWebPage = groupWebPage
self.groupDomain = groupDomain
self.simplexName = simplexName
self.domainWebPage = domainWebPage
self.allowEmbedding = allowEmbedding
}
public var groupWebPage: String?
public var groupDomain: String?
public var simplexName: SimplexNameClaim?
public var domainWebPage: Bool = false
public var allowEmbedding: Bool = false
}
public struct SimplexNameClaim: Codable, Hashable {
public init(name: String, proof: NameClaimProof? = nil) {
self.name = name
self.proof = proof
}
public var name: String
public var proof: NameClaimProof?
public var shortName: String {
name.hasPrefix("simplex:/name") ? String(name.dropFirst("simplex:/name".count)) : name
}
}
public enum SimplexNameError: Decodable, Hashable {
case noValidLink
case unknownName
}
public struct RelayCapabilities: Codable, Hashable {
public var webDomain: String?
}
@@ -5258,28 +5283,70 @@ public enum SimplexLinkType: String, Decodable, Hashable {
}
}
public struct SimplexNameInfo: Decodable, Equatable, Hashable {
public struct SimplexNameInfo: Codable, Equatable, Hashable {
public var nameType: SimplexNameType
public var nameDomain: SimplexNameDomain
// prefix-less domain for prefilling the set-name field (mirrors shortName without the @/# prefix)
public var editDomain: String {
if nameType == .publicGroup, nameDomain.nameTLD == .simplex, nameDomain.subDomain.isEmpty {
return nameDomain.domain
}
return nameDomain.fullDomainName
}
// user-facing display string, mirrors backend shortNameInfoStr
public var shortName: String {
(nameType == .publicGroup ? "#" : "@") + editDomain
}
public init(nameType: SimplexNameType, nameDomain: SimplexNameDomain) {
self.nameType = nameType
self.nameDomain = nameDomain
}
}
public struct SimplexNameDomain: Decodable, Equatable, Hashable {
public struct SimplexNameDomain: Codable, Equatable, Hashable {
public var nameTLD: SimplexTLD
public var domain: String
public var subDomain: [String]
// mirrors backend fullDomainName: reverse(subDomain) ++ [domain] ++ tld
public var fullDomainName: String {
let tld: [String]
switch nameTLD {
case .simplex: tld = ["simplex"]
case .testing: tld = ["testing"]
case .web: tld = []
}
return (subDomain.reversed() + [domain] + tld).joined(separator: ".")
}
public init(nameTLD: SimplexTLD, domain: String, subDomain: [String]) {
self.nameTLD = nameTLD
self.domain = domain
self.subDomain = subDomain
}
}
public enum SimplexTLD: String, Decodable, Hashable {
public enum SimplexTLD: String, Codable, Hashable {
case simplex
case testing
case web
}
public enum SimplexNameType: String, Decodable, Hashable {
public enum SimplexNameType: String, Codable, Hashable {
case publicGroup
case contact
}
// peer's signed name claim; UI only checks presence
public struct NameClaimProof: Codable, Hashable {
public var presHeader: String
public var signature: String
public var linkOwnerId: String?
}
public enum FormatColor: String, Decodable, Hashable {
case red = "red"
case green = "green"
@@ -2068,7 +2068,9 @@ data class LocalProfile(
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null,
val localBadge: LocalBadge? = null
val localBadge: LocalBadge? = null,
val simplexName: SimplexNameClaim? = null,
val contactDomainVerification: Boolean? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
@@ -2198,6 +2200,7 @@ data class GroupInfo (
val chatTags: List<Long>,
val chatItemTTL: Long?,
override val localAlias: String,
val groupDomainVerification: Boolean? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
@@ -2319,10 +2322,18 @@ object GroupTypeSerializer : KSerializer<GroupType> {
}
}
@Serializable
data class SimplexNameClaim(
val name: String,
val proof: NameClaimProof? = null
) {
val shortName: String get() = name.removePrefix("simplex:/name")
}
@Serializable
data class PublicGroupAccess(
val groupWebPage: String? = null,
val groupDomain: String? = null,
val simplexName: SimplexNameClaim? = null,
val domainWebPage: Boolean = false,
val allowEmbedding: Boolean = false
)
@@ -4875,14 +4886,35 @@ enum class SimplexLinkType(val linkType: String) {
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameDomain: SimplexNameDomain
)
) {
// prefix-less domain for prefilling the set-name field (shortName without the @/# prefix)
val editDomain: String
get() = if (nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty())
nameDomain.domain
else nameDomain.fullDomainName
// user-facing display string, mirrors backend shortNameInfoStr
val shortName: String
get() = (if (nameType == SimplexNameType.publicGroup) "#" else "@") + editDomain
}
@Serializable
data class SimplexNameDomain(
val nameTLD: SimplexTLD,
val domain: String,
val subDomain: List<String>
)
) {
// mirrors backend fullDomainName: reverse(subDomain) + [domain] + tld
val fullDomainName: String
get() {
val tld = when (nameTLD) {
SimplexTLD.simplex -> listOf("simplex")
SimplexTLD.testing -> listOf("testing")
SimplexTLD.web -> emptyList()
}
return (subDomain.reversed() + domain + tld).joinToString(".")
}
}
@Serializable
enum class SimplexTLD {
@@ -4897,6 +4929,14 @@ enum class SimplexNameType {
@SerialName("contact") contact
}
// peer's signed name claim; UI only checks presence
@Serializable
data class NameClaimProof(
val presHeader: String,
val signature: String,
val linkOwnerId: String? = null
)
@Serializable
enum class FormatColor(val color: String) {
red("red"),
@@ -122,6 +122,7 @@ class AppPreferences {
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, true)
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, false)
// TODO remove
@@ -397,6 +398,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES = "PrivacyVerifySimplexNames"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert"
private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks"
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove
@@ -1555,6 +1557,27 @@ object ChatController {
generalGetString(MR.strings.link_requires_newer_app_version_please_upgrade)
)
}
r is API.Error && r.err is ChatError.ChatErrorChat
&& r.err.errorType is ChatErrorType.SimplexName -> {
if (r.err.errorType.simplexNameError is SimplexNameError.NoValidLink) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.cannot_reconnect_via_simplex_name),
generalGetString(MR.strings.simplex_name_unprepared_desc)
)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.simplex_name_not_found),
generalGetString(MR.strings.simplex_name_not_found_desc)
)
}
}
r is API.Error && r.err is ChatError.ChatErrorAgent
&& r.err.agentError is AgentErrorType.NO_NAME_SERVERS -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.simplex_name_resolution_unavailable),
generalGetString(MR.strings.simplex_name_resolver_unavailable_desc)
)
}
r is API.Error && r.err is ChatError.ChatErrorAgent
&& r.err.agentError is AgentErrorType.SMP
&& r.err.agentError.smpErr is SMPErrorType.AUTH -> {
@@ -1592,6 +1615,12 @@ object ChatController {
generalGetString(MR.strings.invalid_connection_link)
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.UnsupportedConnReq ->
generalGetString(MR.strings.unsupported_connection_link)
e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.SimplexName ->
if (e.errorType.simplexNameError is SimplexNameError.NoValidLink)
generalGetString(MR.strings.cannot_reconnect_via_simplex_name)
else generalGetString(MR.strings.simplex_name_not_found)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.NO_NAME_SERVERS ->
generalGetString(MR.strings.simplex_name_resolution_unavailable)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.AUTH ->
generalGetString(MR.strings.connection_error_auth)
e is ChatError.ChatErrorAgent && e.agentError is AgentErrorType.SMP && e.agentError.smpErr is SMPErrorType.BLOCKED ->
@@ -1762,6 +1791,31 @@ object ChatController {
}
}
// name is the encoded SimplexName (e.g. "@alice.simplex"); null clears it. Throws on rejection.
suspend fun apiSetUserName(rh: Long?, name: String?): User {
val userId = currentUserId("apiSetUserName")
val r = sendCmd(rh, CC.ApiSetUserName(userId, name))
return when {
r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh)
r is API.Result && r.res is CR.UserProfileNoChange -> r.res.user.updateRemoteHostId(rh)
else -> throw Exception("failed to set SimpleX name: ${r.responseType} ${r.details}")
}
}
suspend fun apiVerifyContactName(rh: Long?, contactId: Long): Pair<Contact, String?>? {
val r = sendCmd(rh, CC.ApiVerifyContactName(contactId))
if (r is API.Result && r.res is CR.ContactNameVerified) return r.res.contact to r.res.verificationFailure
Log.e(TAG, "apiVerifyContactName bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiVerifyPublicGroupName(rh: Long?, groupId: Long): Pair<GroupInfo, String?>? {
val r = sendCmd(rh, CC.ApiVerifyPublicGroupName(groupId))
if (r is API.Result && r.res is CR.GroupNameVerified) return r.res.groupInfo to r.res.verificationFailure
Log.e(TAG, "apiVerifyPublicGroupName bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSetContactPrefs(rh: Long?, contactId: Long, prefs: ChatPreferences): Contact? {
val r = sendCmd(rh, CC.ApiSetContactPrefs(contactId, prefs))
if (r is API.Result && r.res is CR.ContactPrefsUpdated) return r.res.toContact
@@ -2289,7 +2343,7 @@ object ChatController {
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}")
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), r.err.string)
null
}
else -> {
@@ -2303,6 +2357,21 @@ object ChatController {
}
}
suspend fun apiSetPublicGroupAccess(rh: Long?, groupId: Long, access: PublicGroupAccess): GroupInfo? {
val r = sendCmd(rh, CC.ApiSetPublicGroupAccess(groupId, access))
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), r.err.string)
null
}
else -> {
Log.e(TAG, "apiSetPublicGroupAccess bad response: ${r.responseType} ${r.details}")
null
}
}
}
suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? {
val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole))
if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink
@@ -3705,6 +3774,7 @@ sealed class CC {
class ApiLeaveGroup(val groupId: Long): CC()
class ApiListMembers(val groupId: Long): CC()
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
class ApiSetPublicGroupAccess(val groupId: Long, val access: PublicGroupAccess): CC()
class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIGroupLinkMemberRole(val groupId: Long, val memberRole: GroupMemberRole): CC()
class APIDeleteGroupLink(val groupId: Long): CC()
@@ -3775,6 +3845,9 @@ sealed class CC {
class ApiShowMyAddress(val userId: Long): CC()
class ApiAddMyAddressShortLink(val userId: Long): CC()
class ApiSetProfileAddress(val userId: Long, val on: Boolean): CC()
class ApiSetUserName(val userId: Long, val name: String?): CC()
class ApiVerifyContactName(val contactId: Long): CC()
class ApiVerifyPublicGroupName(val groupId: Long): CC()
class ApiSetAddressSettings(val userId: Long, val addressSettings: AddressSettings): CC()
class ApiGetCallInvitations: CC()
class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC()
@@ -3983,6 +4056,10 @@ sealed class CC {
is ApiShowMyAddress -> "/_show_address $userId"
is ApiAddMyAddressShortLink -> "/_short_link_address $userId"
is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}"
is ApiSetUserName -> "/_set_name $userId" + (if (name != null) " $name" else "")
is ApiSetPublicGroupAccess -> "/_public group access #$groupId ${json.encodeToString(access)}"
is ApiVerifyContactName -> "/_verify name @$contactId"
is ApiVerifyPublicGroupName -> "/_verify name #$groupId"
is ApiSetAddressSettings -> "/_address_settings $userId ${json.encodeToString(addressSettings)}"
is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
@@ -4164,6 +4241,10 @@ sealed class CC {
is ApiShowMyAddress -> "apiShowMyAddress"
is ApiAddMyAddressShortLink -> "apiAddMyAddressShortLink"
is ApiSetProfileAddress -> "apiSetProfileAddress"
is ApiSetUserName -> "apiSetUserName"
is ApiSetPublicGroupAccess -> "apiSetPublicGroupAccess"
is ApiVerifyContactName -> "apiVerifyContactName"
is ApiVerifyPublicGroupName -> "apiVerifyPublicGroupName"
is ApiSetAddressSettings -> "apiSetAddressSettings"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
@@ -4443,8 +4524,8 @@ data class ServerOperator(
serverDomains = listOf("simplex.im"),
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false),
enabled = true,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
)
}
@@ -4504,7 +4585,8 @@ data class ServerOperator(
@Serializable
data class ServerRoles(
val storage: Boolean,
val proxy: Boolean
val proxy: Boolean,
val names: Boolean
)
@Serializable
@@ -4526,8 +4608,8 @@ data class UserOperatorServers(
serverDomains = emptyList(),
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
enabled = false,
smpRoles = ServerRoles(storage = true, proxy = true),
xftpRoles = ServerRoles(storage = true, proxy = true)
smpRoles = ServerRoles(storage = true, proxy = true, names = true),
xftpRoles = ServerRoles(storage = true, proxy = true, names = false)
)
companion object {
@@ -4613,6 +4695,7 @@ sealed class UserServersError {
@Serializable
sealed class UserServersWarning {
@Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning()
@Serializable @SerialName("noNamesServers") data class NoNamesServers(val user: UserRef? = null): UserServersWarning()
val globalWarning: String?
get() = when (this) {
@@ -4622,6 +4705,12 @@ sealed class UserServersWarning {
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
} else text
}
is NoNamesServers -> {
val text = generalGetString(MR.strings.no_names_servers_enabled)
if (user != null) {
String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text
} else text
}
}
}
@@ -6462,6 +6551,8 @@ sealed class CR {
@Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR()
@Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
@Serializable @SerialName("contactNameVerified") class ContactNameVerified(val user: UserRef, val contact: Contact, val verificationFailure: String? = null): CR()
@Serializable @SerialName("groupNameVerified") class GroupNameVerified(val user: UserRef, val groupInfo: GroupInfo, val verificationFailure: String? = null): CR()
@Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>, val relaysChanged: Boolean): CR()
@Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR()
@Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR()
@@ -6653,6 +6744,8 @@ sealed class CR {
is JoinedGroupMember -> "joinedGroupMember"
is ConnectedToGroupMember -> "connectedToGroupMember"
is GroupUpdated -> "groupUpdated"
is ContactNameVerified -> "contactNameVerified"
is GroupNameVerified -> "groupNameVerified"
is GroupLinkDataUpdated -> "groupLinkDataUpdated"
is GroupRelayUpdated -> "groupRelayUpdated"
is GroupLinkCreated -> "groupLinkCreated"
@@ -6837,6 +6930,8 @@ sealed class CR {
is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact")
is GroupUpdated -> withUser(user, json.encodeToString(toGroup))
is ContactNameVerified -> withUser(user, "contact: ${json.encodeToString(contact)}\nverificationFailure: $verificationFailure")
is GroupNameVerified -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nverificationFailure: $verificationFailure")
is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged")
is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay")
is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink")
@@ -6960,6 +7055,12 @@ sealed class OwnerVerification {
@Serializable @SerialName("failed") class Failed(val reason: String) : OwnerVerification()
}
@Serializable
sealed class SimplexNameError {
@Serializable @SerialName("noValidLink") object NoValidLink : SimplexNameError()
@Serializable @SerialName("unknownName") object UnknownName : SimplexNameError()
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -6978,7 +7079,7 @@ sealed class InvitationLinkPlan {
@Serializable
sealed class ContactAddressPlan {
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan()
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): ContactAddressPlan()
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
@@ -6988,7 +7089,7 @@ sealed class ContactAddressPlan {
@Serializable
sealed class GroupLinkPlan {
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan()
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedName: SimplexNameInfo? = null): GroupLinkPlan()
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@@ -7297,6 +7398,7 @@ sealed class ChatErrorType {
is ChatStoreChanged -> "chatStoreChanged"
is ConnectionPlanChatError -> "connectionPlan"
is InvalidConnReq -> "invalidConnReq"
is SimplexName -> "simplexName"
is UnsupportedConnReq -> "unsupportedConnReq"
is InvalidChatMessage -> "invalidChatMessage"
is ConnReqMessageProhibited -> "connReqMessageProhibited"
@@ -7379,6 +7481,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("simplexName") class SimplexName(val simplexName: SimplexNameInfo, val simplexNameError: SimplexNameError): ChatErrorType()
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
@@ -7647,6 +7750,7 @@ sealed class AgentErrorType {
is INTERNAL -> "INTERNAL $internalErr"
is CRITICAL -> "CRITICAL $offerRestart $criticalErr"
is INACTIVE -> "INACTIVE"
is NO_NAME_SERVERS -> "NO_NAME_SERVERS"
}
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType, val errContext: String): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType, val errContext: String): AgentErrorType()
@@ -7661,6 +7765,19 @@ sealed class AgentErrorType {
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
@Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType()
@Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType()
@Serializable @SerialName("NO_NAME_SERVERS") object NO_NAME_SERVERS: AgentErrorType()
}
@Serializable
sealed class NameErrorType {
val string: String get() = when (this) {
is NO_RESOLVER -> "NO_RESOLVER"
is NOT_FOUND -> "NOT_FOUND"
is RESOLVER -> "RESOLVER $resolverErr"
}
@Serializable @SerialName("NO_RESOLVER") object NO_RESOLVER: NameErrorType()
@Serializable @SerialName("NOT_FOUND") object NOT_FOUND: NameErrorType()
@Serializable @SerialName("RESOLVER") class RESOLVER(val resolverErr: String): NameErrorType()
}
@Serializable
@@ -7730,6 +7847,7 @@ sealed class SMPErrorType {
is LARGE_MSG -> "LARGE_MSG"
is EXPIRED -> "EXPIRED"
is INTERNAL -> "INTERNAL"
is NAME -> "NAME ${nameErr.string}"
}
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
@@ -7744,6 +7862,7 @@ sealed class SMPErrorType {
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
@Serializable @SerialName("NAME") class NAME(val nameErr: NameErrorType): SMPErrorType()
}
@Serializable
@@ -757,6 +757,21 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName)
)
ChatInfoDescription(cInfo, displayName, copyNameToClipboard)
val contactDomain = contact.profile.simplexName?.shortName
if (contactDomain != null && contact.profile.simplexName?.proof != null) {
SimplexNameView(
name = contactDomain,
verification = contact.profile.contactDomainVerification,
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
verify = {
val rhId = chatModel.remoteHostId()
chatModel.controller.apiVerifyContactName(rhId, contact.contactId)?.let { (ct, reason) ->
chatModel.chatsContext.updateContact(rhId, ct)
ct.profile.contactDomainVerification to reason
}
}
)
}
}
}
@@ -0,0 +1,95 @@
package chat.simplex.common.views.chat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.SimplexNameInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
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.*
// Renders a contact's / channel's SimpleX name with its 3-state verification indicator.
// `verification`: null = not attempted, false = failed, true = verified.
// `verify` runs the verify API, updates the model and returns (newVerification, failureReason);
// null on network error. With `autoVerify`, it runs once on open when state is null.
@Composable
fun SimplexNameView(
name: String,
verification: Boolean?,
autoVerify: Boolean,
verify: suspend () -> Pair<Boolean?, String?>?
) {
val scope = rememberCoroutineScope()
val inFlight = remember { mutableStateOf(false) }
val showSpinner = remember { mutableStateOf(false) }
fun runVerify(manual: Boolean) {
if (inFlight.value) return
inFlight.value = true
scope.launch {
// delay the spinner so a fast result on open doesn't flash it
val spinner = launch { delay(300); if (inFlight.value) showSpinner.value = true }
val res = try {
verify()
} catch (e: Exception) {
Log.e(TAG, "verify SimplexName: ${e.stackTraceToString()}")
null
}
spinner.cancel()
inFlight.value = false
showSpinner.value = false
if (res != null) {
val (newV, reason) = res
// show the reason on a manual run, or on an inconclusive auto run (state stayed null)
if (reason != null && (manual || newV == null)) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.simplex_name_not_verified), reason)
}
}
}
}
LaunchedEffect(Unit) {
if (autoVerify && verification == null) runVerify(manual = false)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
) {
Text(
name,
style = MaterialTheme.typography.body2.copy(
color = if (verification == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
fontFamily = if (verification == true) FontFamily.Default else FontFamily.Monospace
)
)
when {
showSpinner.value ->
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary)
verification == true ->
Icon(painterResource(MR.images.ic_check_filled), null, Modifier.size(18.dp), tint = MaterialTheme.colors.onBackground)
verification == false ->
Icon(
painterResource(MR.images.ic_close), null, tint = Color.Red,
modifier = Modifier.size(18.dp).clickable { runVerify(manual = true) }
)
else ->
Text(
stringResource(MR.strings.verify_simplex_name_action),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable { runVerify(manual = true) }
)
}
}
}
@@ -49,7 +49,7 @@ fun ChannelWebPageView(
val trimmedPage = webPage.value.trim()
val newAccess = PublicGroupAccess(
groupWebPage = trimmedPage.ifEmpty { null },
groupDomain = access?.groupDomain,
simplexName = access?.simplexName,
domainWebPage = access?.domainWebPage ?: false,
allowEmbedding = allowEmbedding.value
)
@@ -178,6 +178,26 @@ fun ModalData.GroupChatInfoView(
manageWebPage = {
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
},
setSimplexName = {
ModalManager.end.showCustomModal { close ->
SetSimplexNameView(
title = generalGetString(MR.strings.set_simplex_name),
footer = generalGetString(MR.strings.set_channel_simplex_name_footer),
prefix = "#",
initial = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.simplexName?.shortName ?: "",
save = { name ->
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess ?: PublicGroupAccess()
val newAccess = access.copy(simplexName = name?.let { SimplexNameClaim(it) })
val gInfo = chatModel.controller.apiSetPublicGroupAccess(rhId, groupInfo.groupId, newAccess)
if (gInfo != null) {
withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) }
true
} else false
},
close = close
)
}
},
onSearchClicked = onSearchClicked,
deletingItems = deletingItems
)
@@ -510,6 +530,7 @@ fun ModalData.GroupChatInfoLayout(
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
manageWebPage: () -> Unit,
setSimplexName: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit,
deletingItems: State<Boolean>
@@ -804,6 +825,14 @@ fun ModalData.GroupChatInfoLayout(
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.advanced_options)) {
ChannelWebPageButton(groupInfo, manageWebPage)
if (groupInfo.groupProfile.publicGroup?.publicGroupAccess != null) {
SettingsActionItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.set_simplex_name),
setSimplexName,
iconColor = MaterialTheme.colors.secondary
)
}
}
}
@@ -945,6 +974,22 @@ 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 groupName = access?.simplexName?.shortName
if (groupName != null && access.simplexName?.proof != null) {
SimplexNameView(
name = groupName,
verification = groupInfo.groupDomainVerification,
autoVerify = chatModel.controller.appPrefs.privacyVerifySimplexNames.get(),
verify = {
val rhId = chatModel.remoteHostId()
chatModel.controller.apiVerifyPublicGroupName(rhId, groupInfo.groupId)?.let { (gInfo, reason) ->
chatModel.chatsContext.updateGroup(rhId, gInfo)
gInfo.groupDomainVerification to reason
}
}
)
}
val webPage = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
if (webPage != null) {
val uriHandler = LocalUriHandler.current
@@ -1436,6 +1481,7 @@ fun PreviewGroupChatInfoLayout() {
manageGroupLink = {},
manageWebPage = {},
onSearchClicked = {},
setSimplexName = {},
deletingItems = remember { mutableStateOf(true) }
)
}
@@ -338,13 +338,10 @@ fun MarkdownText (
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
withAnnotation("SIMPLEX_NAME") { a ->
val idx = a.item.toIntOrNull()
val nameInfo = (idx?.let { formattedText.getOrNull(it) }?.format as? Format.SimplexName)?.nameInfo
val (title, msg) = if (nameInfo?.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
val nameText = idx?.let { formattedText.getOrNull(it) }?.text
// The name string is routed through the same connect path as a
// link; planAndConnect resolves it on the core (name target).
if (nameText != null) uriHandler.openVerifiedSimplexUri(nameText)
}
}
if (hasSecrets) {
@@ -800,7 +800,20 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = null,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
@@ -31,11 +31,6 @@ suspend fun planAndConnect(
filterKnownGroup: ((GroupInfo) -> Unit)? = null,
): CompletableDeferred<Boolean> {
when (val target = strConnectTarget(shortOrFullLink.trim())) {
is ConnectTarget.Name -> {
showUnsupportedNameAlert(target.nameInfo)
cleanup?.invoke()
return CompletableDeferred(false)
}
is ConnectTarget.Link -> {
if (target.linkType == SimplexLinkType.relay) {
AlertManager.privacySensitive.showAlertMsg(
@@ -46,7 +41,9 @@ suspend fun planAndConnect(
return CompletableDeferred(false)
}
}
null -> {}
// A SimplexName falls through to apiConnectPlan, which resolves it on the
// core (the /_connect plan command accepts a name target, not only a link).
is ConnectTarget.Name, null -> {}
}
connectProgressManager.cancelConnectProgress()
val inProgress = mutableStateOf(true)
@@ -204,6 +201,12 @@ private suspend fun planAndConnectTask(
is ContactAddressPlan.Known -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .Known")
val contact = connectionPlan.contactAddressPlan.contact
// A name-resolved contact is prepared in the store but not yet in the
// chat list (link-prepared chats arrive via NewPreparedChat). Surface it
// so it's visible and openable; no-op if already present.
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
@@ -288,6 +291,11 @@ private suspend fun planAndConnectTask(
is GroupLinkPlan.Known -> {
Log.d(TAG, "planAndConnect, .GroupLink, .Known")
val groupInfo = connectionPlan.groupLinkPlan.groupInfo
// Same as ContactAddress.Known: surface a name-resolved (prepared)
// group in the chat list so it's visible and openable.
if (chatModel.getGroupChat(groupInfo.groupId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(groupInfo, groupChatScope = null), chatItems = emptyList()))
}
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
@@ -536,7 +536,20 @@ private fun ContactsSearchBar(
cleanup = { searchText.value = TextFieldValue() }
)
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = close,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
@@ -679,7 +679,11 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState<String>, showQRC
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
is ConnectTarget.Name -> showUnsupportedNameAlert(target.nameInfo)
is ConnectTarget.Name -> {
pastedLink.value = target.text
showQRCodeScanner.value = false
withBGApi { connect(rhId, target.text, close) { pastedLink.value = "" } }
}
null -> AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_contact_link),
text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link)
@@ -824,7 +828,7 @@ fun strIsSimplexLink(str: String): Boolean {
sealed class ConnectTarget {
class Link(val text: String, val linkType: SimplexLinkType, val linkText: String) : ConnectTarget()
class Name(val nameInfo: SimplexNameInfo) : ConnectTarget()
class Name(val text: String, val nameInfo: SimplexNameInfo) : ConnectTarget()
}
fun strConnectTarget(str: String): ConnectTarget? {
@@ -835,21 +839,13 @@ fun strConnectTarget(str: String): ConnectTarget? {
return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText)
}
if (links.isEmpty()) {
val nameInfo = parsedMd.firstNotNullOfOrNull { (it.format as? Format.SimplexName)?.nameInfo }
if (nameInfo != null) return ConnectTarget.Name(nameInfo)
val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName }
val nameInfo = (nameFt?.format as? Format.SimplexName)?.nameInfo
if (nameFt != null && nameInfo != null) return ConnectTarget.Name(nameFt.text, nameInfo)
}
return null
}
fun showUnsupportedNameAlert(nameInfo: SimplexNameInfo) {
val (title, msg) = if (nameInfo.nameType == SimplexNameType.contact) {
generalGetString(MR.strings.unsupported_contact_name) to generalGetString(MR.strings.contact_name_requires_newer_app_version)
} else {
generalGetString(MR.strings.unsupported_channel_name) to generalGetString(MR.strings.channel_name_requires_newer_app_version)
}
AlertManager.shared.showAlertMsg(title, "$msg ${generalGetString(MR.strings.please_upgrade_the_app)}")
}
@Composable
fun IncognitoToggle(
incognitoPref: SharedPreference<Boolean>,
@@ -134,6 +134,11 @@ fun MorePrivacyView(chatModel: ChatModel) {
chatModel.draftChatId.value = null
}
})
SettingsPreferenceItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.verify_simplex_names),
chatModel.controller.appPrefs.privacyVerifySimplexNames
)
}
SectionDividerSpaced()
@@ -0,0 +1,75 @@
package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import chat.simplex.common.platform.*
import chat.simplex.common.views.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
// 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).
@Composable
fun SetSimplexNameView(
title: String,
footer: String,
prefix: String,
initial: String,
save: suspend (String?) -> Boolean,
close: () -> Unit
) {
val name = rememberSaveable { mutableStateOf(initial) }
val saving = remember { mutableStateOf(false) }
val unchanged = name.value.trim() == initial.trim()
fun normalized(): String? {
val s = name.value.trim()
return when {
s.isEmpty() -> null
s.startsWith("@") || s.startsWith("#") -> prefix + s.substring(1)
else -> prefix + s
}
}
val doSave: () -> Unit = {
withBGApi {
saving.value = true
val ok = try { save(normalized()) } catch (e: Exception) {
Log.e(TAG, "SetSimplexNameView 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() }
}
}
ModalView(close = close) {
ColumnWithScrollBar {
AppBarTitle(title)
SectionView {
PlainTextEditor(name, placeholder = prefix + stringResource(MR.strings.simplex_name_placeholder))
}
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
)
}
}
SectionBottomSpacer()
}
}
}
@@ -34,6 +34,8 @@ import chat.simplex.common.views.chat.*
import chat.simplex.common.views.newchat.*
import chat.simplex.common.BuildConfigCommon
import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun UserAddressView(
@@ -355,6 +357,32 @@ private fun UserAddressLayout(
// ShareViaEmailButton { sendEmail(userAddress) }
BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) }
AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings)
SettingsActionItem(
painterResource(MR.images.ic_verified_user),
stringResource(MR.strings.set_simplex_name),
click = {
ModalManager.start.showCustomModal { close ->
SetSimplexNameView(
title = generalGetString(MR.strings.set_simplex_name),
footer = generalGetString(MR.strings.set_user_simplex_name_footer),
prefix = "@",
initial = user?.profile?.simplexName?.shortName ?: "",
save = { name ->
try {
val u = chatModel.controller.apiSetUserName(user?.remoteHostId, name)
withContext(Dispatchers.Main) { chatModel.updateUser(u) }
true
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_simplex_name), e.message ?: "")
false
}
},
close = close
)
}
},
iconColor = MaterialTheme.colors.secondary
)
}
if (addressSettingsState.value.businessAddress) {
SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations))
@@ -276,20 +276,21 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) {
) {
Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
val serversErrs = globalServersErrors(serverErrors.value)
if (serversErrs.isNotEmpty()) {
serversErrs.forEach { err ->
SectionCustomFooter {
ServersErrorFooter(err)
}
}
} else if (serverErrors.value.isNotEmpty()) {
SectionCustomFooter {
ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration))
}
}
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversWarn != null) {
globalServersWarnings(serverWarnings.value).forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(serversWarn)
ServersWarningFooter(warn)
}
}
@@ -951,23 +952,11 @@ fun serversCanBeSaved(
return userServers != currUserServers && serverErrors.isEmpty()
}
fun globalServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
if (err.globalError != null) {
return err.globalError
}
}
return null
}
fun globalServersErrors(serverErrors: List<UserServersError>): List<String> =
serverErrors.mapNotNull { it.globalError }
fun globalServersWarning(serverWarnings: List<UserServersWarning>): String? {
for (warn in serverWarnings) {
if (warn.globalWarning != null) {
return warn.globalWarning
}
}
return null
}
fun globalServersWarnings(serverWarnings: List<UserServersWarning>): List<String> =
serverWarnings.mapNotNull { it.globalWarning }
fun globalSMPServersError(serverErrors: List<UserServersError>): String? {
for (err in serverErrors) {
@@ -211,15 +211,19 @@ fun OperatorViewLayout(
rhId = rhId
)
}
val serversErr = globalServersError(serverErrors.value)
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversErr != null) {
SectionCustomFooter {
ServersErrorFooter(serversErr)
val serversErrs = globalServersErrors(serverErrors.value)
val serversWarns = globalServersWarnings(serverWarnings.value)
if (serversErrs.isNotEmpty()) {
serversErrs.forEach { err ->
SectionCustomFooter {
ServersErrorFooter(err)
}
}
} else if (serversWarn != null) {
SectionCustomFooter {
ServersWarningFooter(serversWarn)
} else if (serversWarns.isNotEmpty()) {
serversWarns.forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(warn)
}
}
} else {
val footerText = when (val c = operator.conditionsAcceptance) {
@@ -267,7 +271,7 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
)
)
}
@@ -287,7 +291,27 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled)
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(proxy = enabled) ?: ServerRoles(storage = false, proxy = enabled, names = false)
)
)
}
}
)
}
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
stringResource(MR.strings.operator_use_for_names),
Modifier.padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = userServers.value[operatorIndex].operator_.smpRoles.names,
onCheckedChange = { enabled ->
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
smpRoles = this[operatorIndex].operator?.smpRoles?.copy(names = enabled) ?: ServerRoles(storage = false, proxy = false, names = enabled)
)
)
}
@@ -371,7 +395,7 @@ fun OperatorViewLayout(
userServers.value = userServers.value.toMutableList().apply {
this[operatorIndex] = this[operatorIndex].copy(
operator = this[operatorIndex].operator?.copy(
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false)
xftpRoles = this[operatorIndex].operator?.xftpRoles?.copy(storage = enabled) ?: ServerRoles(storage = enabled, proxy = false, names = false)
)
)
}
@@ -185,16 +185,14 @@ fun YourServersViewLayout(
iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
val serversErr = globalServersError(serverErrors.value)
if (serversErr != null) {
globalServersErrors(serverErrors.value).forEach { err ->
SectionCustomFooter {
ServersErrorFooter(serversErr)
ServersErrorFooter(err)
}
}
val serversWarn = globalServersWarning(serverWarnings.value)
if (serversWarn != null) {
globalServersWarnings(serverWarnings.value).forEach { warn ->
SectionCustomFooter {
ServersWarningFooter(serversWarn)
ServersWarningFooter(warn)
}
}
SectionDividerSpaced()
@@ -146,6 +146,7 @@
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="no_chat_relays_enabled">No chat relays enabled.</string>
<string name="no_names_servers_enabled">No servers to resolve names.</string>
<string name="server_warning">Server warning</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<string name="blocking_reason_spam">Spam</string>
@@ -199,6 +200,12 @@
<string name="channel_name_requires_newer_app_version">Connecting via channel name requires a newer app version.</string>
<string name="contact_name_requires_newer_app_version">Connecting via contact name requires a newer app version.</string>
<string name="please_upgrade_the_app">Please upgrade the app.</string>
<string name="simplex_name_not_found">SimpleX name not found</string>
<string name="simplex_name_not_found_desc">There is no contact or group registered with this SimpleX name.</string>
<string name="cannot_reconnect_via_simplex_name">Cannot reconnect via name</string>
<string name="simplex_name_unprepared_desc">This SimpleX name is known but has no saved link to reconnect via.</string>
<string name="simplex_name_resolution_unavailable">Name resolution unavailable</string>
<string name="simplex_name_resolver_unavailable_desc">None of your SMP servers support resolving SimpleX names. Add a server that does, or use a connection link.</string>
<string name="channel_temporarily_unavailable">Channel temporarily unavailable</string>
<string name="channel_no_active_relays_try_later">Channel has no active relays. Please try to join later.</string>
<string name="app_update_required">App update required</string>
@@ -929,7 +936,15 @@
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
<string name="simplex_address">SimpleX address and name</string>
<string name="verify_simplex_name_action">Verify name</string>
<string name="verify_simplex_names">Verify SimpleX names</string>
<string name="simplex_name_not_verified">SimpleX name not verified</string>
<string name="set_simplex_name">Set SimpleX name</string>
<string name="simplex_name_placeholder">name.simplex</string>
<string name="error_saving_simplex_name">Error saving SimpleX name</string>
<string name="set_user_simplex_name_footer">Set a SimpleX name so people can connect to you using @yourname instead of a link. The name must already be registered to your address.</string>
<string name="set_channel_simplex_name_footer">Set a SimpleX name so people can find this channel as #name. The name must be registered to this channel\'s address.</string>
<string name="or_show_this_qr_code">Or show this code</string>
<string name="full_link_button_text">Full link</string>
<string name="short_link_button_text">Short link</string>
@@ -2153,6 +2168,7 @@
<string name="operator_use_for_messages">Use for messages</string>
<string name="operator_use_for_messages_receiving">To receive</string>
<string name="operator_use_for_messages_private_routing">For private routing</string>
<string name="operator_use_for_names">To resolve names</string>
<string name="operator_added_message_servers">Added message servers</string>
<string name="operator_use_for_files">Use for files</string>
<string name="operator_use_for_sending">To send</string>
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 20ccd40f75e8de375e3274d0fac96766bf16872e
tag: 97029cf58cc5009d5301ef120f39518680dd004b
source-repository-package
type: git
@@ -0,0 +1,243 @@
# SimpleX name UI: display + verify + set (iOS + Android/desktop)
Branch: `sh/namespace-ui` (rebased onto core `fc0582cf0` — the finalized verify API). This pass adds
displaying a contact's / channel's SimpleX name with verification state, verifying names (manual + auto),
and two screens for setting the user's own name and a channel's name.
**Upstream sync (2026-06-27):** core `sh/namespace` is at `5008b4e62` ("refactor setting user name" +
test/comment cleanup, on top of the verify-API split `fc0582cf0`). UI branch rebased onto it (backup tag
`backup-namespace-ui-pre-rebase5`); pure-frontend, rebase clean. All dep-touching changes across these
syncs were **wire-neutral**: cosmetic StrJSON label on `Profile.contactDomain`; `APISetUserName` handler
refactor (constructor `{userId, simplexName}`, `/_set_name` parser, `CRUserProfileUpdated`/`NoChange`
response unchanged); `Internal.hs` record-field reordering in bot profiles; store/view/test cleanup.
`Types.hs` model fields/JSON, verify/set commands, responses, `NameVerifyOutcome`, `NameClaimProof`, and
`SimplexNameInfo` are unchanged. **No UI code change required.**
## Scope
Ships (both iOS and Android/desktop):
- Models: decode name / proof / verification fields, add `NameClaimProof` + `SimplexNameInfo` helpers.
- Name display + 3-state verification indicator on contact info and channel info.
- Verify API calls + new response handling.
- "Verify SimpleX names" privacy toggle (default ON).
- Two "Set SimpleX name" screens (own name; channel name).
Out: no change to the core verify algorithm — the UI only triggers it and renders the result.
## Decisions
Single source of truth. The UX walkthrough shows the visuals; the implementation sections give file:line.
### Product / UX (confirmed)
- **A. Title** — rename to "SimpleX address and name".
- **B. Screen body copy** — placeholders for now (final copy TBD):
- Own: *"Set a SimpleX name so people can connect to you using @yourname instead of a link. The name
must already be registered to your address."*
- Channel: *"Set a SimpleX name so people can find this channel as #name. The name must be registered
to this channel's address."*
- **C. Own name read-only** — No; the current own name appears only inside the set screen.
- **D. Rename scope** — rename the shared string everywhere (settings-menu row + screen title).
- **E. Auto-verify trigger** — with the toggle ON, auto-verify on open only if stored state is `null`;
verified/failed shows the stored result, and tapping the indicator re-verifies.
- **F. Failure reason** — shown on tap of the red cross (alert); an inconclusive result shows a brief
alert on completion. Not shown inline.
### Technical approach
- **T1. Show the name only when a proof exists** (`contactDomainProof` / `groupDomainProof` != nil).
- **T2. One formatter + one parser.** `SimplexNameInfo.shortName` (display, mirrors `shortNameInfoStr`),
`editDomain` (prefix-less, prefills set fields), `SimplexNameInfo(parsing:)` (decode the encoded
`groupDomain` string). Contact display uses the decoded object; channel display parses `groupDomain`.
- **T3. Set screens send a name string; UI fixes only the type prefix.** `strP` reads `@`->contact /
`#`->group first, so the UI prepends `@` (own) / `#` (channel); the backend canonicalises the domain.
Empty input clears.
- **T4. Channel uses a raw `APIUpdateGroupProfile`** (documented at the call site): core has
`APISetUserName` but no `APISetGroupName`, so the channel name is set by re-sending the cloned
`GroupProfile` with `publicGroup.publicGroupAccess.groupDomain` updated.
- **T5. Gating.** Channel "Set SimpleX name" only when `useRelays && isOwner && publicGroup?.publicGroupAccess != nil`;
own "Set SimpleX name" lives in the existing-address branch (inherently requires an address).
Minor defaults (flag if wrong): iOS toggle in the first "Chats" Section (PrivacySettings.swift:85);
spinner delay ~300ms; channel name cleared via empty input; set-name fields rely on backend rejection
for validation (+ helper text).
## UX walkthrough
Same on both platforms. Nothing existing moves.
### Name + verification indicator (contact info / channel info)
A new line under the name, above the description, shown per T1.
```
Contact info Channel info
+------------------+ +------------------+
| [ photo ] | | [ photo ] |
| Alice | | My Team |
| @alice.simplex v| <- new | #myteam Verify | <- new
| "description..."| | "description..."|
+------------------+ +------------------+
(v = check) (Verify = action)
```
Name on the left, indicator on the right; the indicator depends on state:
| State (`*Verification`) | Name style | Indicator |
|---|---|---|
| Verified (`true`) | accent color | check mark in regular color |
| Failed (`false`) | code style | cross mark in red (tap -> failure reason, per F) |
| Not verified (`null`), toggle OFF | code style | "Verify name" action (accent) |
| Not verified (`null`), toggle ON | code style | auto-verify on open (per E) -> spinner -> result |
| Verifying (in-flight) | code style | delayed spinner (~300ms, so a fast result doesn't flash) |
### Set screens (2 new buttons -> 2 new screens)
1. **Own name** — on the "SimpleX address and name" screen, a new "Set SimpleX name" button just above
the "Or to share privately" section:
```
[ QR code ]
Share address
Business address (toggle)
Address settings
------------------
Set SimpleX name -> <- new
------------------
Or to share privately
Create 1-time link
```
2. **Channel name** — on channel info, in the existing "Advanced options" section (owners only):
```
Advanced options
Web access
Set SimpleX name -> <- new
```
Each opens the same view (parameterised by prefix / body text / save action): explanation + a text field
with a fixed prefix adornment (`@` own -> user types `alice.simplex`; `#` channel -> user types `myteam`)
+ Save. Empty + Save clears the name.
### Settings toggle
"Verify SimpleX names" toggle, default ON, in the privacy settings "Chats" section (iOS:
PrivacySettings.swift:85, no `MorePrivacyView`; Kotlin: `MorePrivacyView` Chats section, PrivacySettings.kt:118).
## Backend reference (core @ fc0582cf0)
Single source for wire/JSON facts.
- Display: `shortNameInfoStr` (simplexmq Protocol.hs:1594) — public group on default `.simplex` TLD with
empty subdomain -> `#myteam`; else prefix (`@`/`#`) + full domain (`@alice.simplex`, `#myteam.testing`).
- Encoded form: `strEncode SimplexNameInfo = "simplex:/name" <> ("@"|"#") <> fullDomain` (Protocol.hs:1565).
- JSON shapes: `LocalProfile.contactDomain :: Maybe SimplexNameInfo` -> JSON **object**;
`PublicGroupAccess.groupDomain :: Maybe (StrJSON SimplexNameInfo)` -> JSON **string** (encoded form).
- Verification: `LocalProfile.contactDomainVerification` / `GroupInfo.groupDomainVerification :: Maybe Bool`
-> JSON `true`/`false`/absent (decodes as Swift `Bool?` / Kotlin `Boolean?`); null=not attempted,
false=failed, true=verified.
- Proof: `LocalProfile.contactDomainProof` / `PublicGroupAccess.groupDomainProof :: Maybe NameClaimProof`
-> JSON object `{linkOwnerId?: string, presHeader: string, signature: string}` (all strings).
- Verify commands (manual; network/resolver errors are retryable `ChatErrorAgent`):
- `APIVerifyContactName {contactId}` -> `/_verify name @<contactId>`.
- `APIVerifyPublicGroupName {groupId}` -> `/_verify name #<groupId>`.
- Outcome `NVOVerified | NVOFailed Text | NVOInconclusive Text` -> persists `Just True` / `Just False` /
leaves unchanged. Responses `CRContactNameVerified {user, contact, verificationResult :: Maybe Text}` /
`CRGroupNameVerified {user, groupInfo, verificationResult :: Maybe Text}` return the **updated** entity
plus `Nothing`=verified / `Just reason`=failure-or-inconclusive text.
- Set own name: `APISetUserName userId (Maybe SimplexNameInfo)` -> `/_set_name <userId> [<name>]` (parsed by
`strP`, NOT json; Commands.hs:5438). Rejects `name is not registered to your address`. Response
`CRUserProfileUpdated` / `CRUserProfileNoChange`.
- Set channel name: `APIUpdateGroupProfile` (`/_group_profile #<id> <json>`, Commands.hs:5571) with
`groupDomain` set. Rejects `name is not registered to this channel`. Response `CRGroupUpdated`.
## Gotchas
1. **iOS Codable (compile-blocking).** `SimplexNameInfo`/`SimplexNameDomain`/`SimplexTLD`/`SimplexNameType`
are `Decodable`-only (ChatTypes.swift:5261-5281); adding `contactDomain` to the `Codable` `LocalProfile`
breaks `Encodable` synthesis -> make all four `Codable`. (Kotlin already `@Serializable`.)
2. **`groupDomain` string carries the `simplex:/name` prefix** -> strip it in `parsing`.
3. **`NameClaimProof` decoded for presence only.** UI just checks `!= nil`; if any field's JSON shape is
uncertain at implementation, decode permissively.
4. **Delayed spinner — no existing helper.** iOS: `@State var verifying` set true after `Task.sleep(~300ms)`,
guarded by an `inFlight` flag. Android: a small `LaunchedEffect(inFlight){ delay(300); show=true }` composable.
5. **Enum JSON parity** — backend `enumJSON (dropPrefix "TLD"/"NT")` lowercases -> matches the Swift/Kotlin
enum raw values.
6. **Navigation.** iOS `NavigationLink`; Android `ModalManager.start.showModalCloseable { ... }`
(template: PrivacySettings.kt:162).
---
## iOS implementation
### Models — `apps/ios/SimpleXChat/ChatTypes.swift`
- Make `SimplexNameInfo`/`SimplexNameDomain`/`SimplexTLD`/`SimplexNameType` (5261-5281) `Codable`.
- Add `NameClaimProof: Codable, Hashable { presHeader: String; signature: String; linkOwnerId: String? }`.
- `SimplexNameInfo` (5261): add `init?(parsing: String)`, `var shortName: String`, `var editDomain: String`.
- `LocalProfile` (153): add `contactDomain: SimplexNameInfo?`, `contactDomainProof: NameClaimProof?`,
`contactDomainVerification: Bool?`.
- `GroupInfo` (2506): add `groupDomainVerification: Bool?`.
- `PublicGroupAccess` (2616): keep `groupDomain: String?`; add `groupDomainProof: NameClaimProof?`.
### API — `apps/ios/Shared/Model/{AppAPITypes,SimpleXAPI}.swift`
- `ChatCommand`: `apiSetUserName(userId: Int64, name: String?)` -> `"/_set_name \(userId)" + (name.map{" "+$0} ?? "")`;
`apiVerifyContactName(contactId: Int64)` -> `"/_verify name @\(contactId)"`;
`apiVerifyPublicGroupName(groupId: Int64)` -> `"/_verify name #\(groupId)"`.
- `ChatResponse1`: add `contactNameVerified(user:contact:verificationResult:)` /
`groupNameVerified(user:groupInfo:verificationResult:)` cases (+ `responseType`/`details` entries).
Templates: `contactUpdated` AppAPITypes.swift:1114 / responseType:1193 / details:1267; `groupUpdated`:1140.
- Wrappers: `apiSetUserName(_:)`; `apiVerifyContactName(_:)` (updated `Contact` + reason);
`apiVerifyPublicGroupName(_:)` (updated `GroupInfo` + reason). Channel set reuses `apiUpdateGroup(_:_:)`.
### Views
- Reusable `SimplexNameView` subview rendering the 5-row state table (incl. delayed spinner + verify action).
- Contact: `ChatInfoView.swift` `contactInfoHeader()` — before the `shortDescr` block (~395), per T1 render
`SimplexNameView` from `contactDomain`/`contactDomainProof`/`contactDomainVerification`; onAppear auto-verify (E).
- Channel: `GroupChatInfoView.swift` `groupInfoHeader()` — before webPage (~334), same from parsed
`groupDomain` + `groupDomainProof` + `groupDomainVerification`; "Set SimpleX name" button in Advanced options (~248).
- Address view: `UserAddressView.swift` — "Set SimpleX name" Section above "Or to share privately" (194).
- New `SetSimplexNameView.swift` (own + channel modes).
- Title rename (A/D): the address screen's title is set by the presenter, not `UserAddressView`
(literal also at UserAddressLearnMore.swift:71) — locate the title source and rename everywhere.
- Toggle: `PrivacySettings.swift` main "Chats" Section (~85) `Toggle("Verify SimpleX names", isOn:)`
bound to `@AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES)`; declare the constant + default `true` in
the `appDefaults` dict (SettingsView.swift:88-105).
## Android/desktop implementation (multiplatform Kotlin)
### Models — `model/ChatModel.kt`
- Add `@Serializable data class NameClaimProof(presHeader: String, signature: String, linkOwnerId: String? = null)`.
- `SimplexNameInfo` (4875): add `companion fun parse(encoded): SimplexNameInfo?`, `val shortName`, `val editDomain`.
- `LocalProfile` (2061): add `contactDomain: SimplexNameInfo? = null`, `contactDomainProof: NameClaimProof? = null`,
`contactDomainVerification: Boolean? = null`.
- `GroupInfo` (2181): add `groupDomainVerification: Boolean? = null`.
- `PublicGroupAccess` (2323): keep `groupDomain: String?`; add `groupDomainProof: NameClaimProof? = null`.
### API — `model/SimpleXAPI.kt`
- `CC`: `ApiSetUserName(userId, name: String?)`, `ApiVerifyContactName(contactId)` -> `"/_verify name @$contactId"`,
`ApiVerifyPublicGroupName(groupId)` -> `"/_verify name #$groupId"` (+ cmdString entries).
- `CR`: `@SerialName("contactNameVerified") ContactNameVerified(user, contact, verificationResult: String?)`,
`GroupNameVerified(user, groupInfo, verificationResult: String?)` (+ `responseType`). Templates:
`ContactUpdated` SimpleXAPI.kt:6454 / responseType:6646; `GroupUpdated`:6500.
- Wrappers `apiSetUserName`, `apiVerifyContactName`, `apiVerifyPublicGroupName`; channel set reuses `apiUpdateGroupProfile`.
- Pref: `val privacyVerifySimplexNames = mkBoolPreference(SHARED_PREFS_PRIVACY_VERIFY_SIMPLEX_NAMES, true)` (~123).
### Views
- Reusable `SimplexNameView` composable (the 5-row state table).
- Contact: `views/chat/ChatInfoView.kt` — after `ChatInfoDescription(...)` (~759), per T1; onAppear auto-verify (E).
- Channel: `views/chat/group/GroupChatInfoView.kt` — after `ChatInfoDescription(...)` (~947), per T1;
"Set SimpleX name" button in Advanced options `SectionView` (~805).
- New `views/usersettings/SetSimplexNameView.kt` (own + channel modes).
- `UserAddressView.kt`: "Set SimpleX name" button above the one-time-link section (~347).
- Toggle: `PrivacySettings.kt` `MorePrivacyView` "Chats" Section (~118) `SettingsPreferenceItem(..., appPrefs.privacyVerifySimplexNames)`.
- `strings.xml`: `set_simplex_name`, `verify_simplex_name`, `verify_simplex_names`, screen titles/body;
rename `simplex_address` -> "SimpleX address and name" (D).
## Build / verify
- iOS: Xcode build of SimpleXChat + app (or swift build of the package targets).
- Android/desktop: `./gradlew` compile of `:common` (desktop target fastest).
- Manual: a peer with a verified name shows accent + check; tampered/failed shows red cross + reason on tap;
toggle OFF shows "Verify name"; toggle ON auto-verifies on open with a delayed spinner. Set own/channel
name; clearing works; core rejection surfaces as an alert.
## Commit plan (conventional)
1. `feat(names): decode name/proof/verification; add SimplexNameInfo helpers + NameClaimProof` (models, both)
2. `feat(names): verify API (contact/group) + response handling` (CC/CR, wrappers)
3. `feat(names): show name + verification state on contact and channel info` (SimplexNameView + headers)
4. `feat(names): "Verify SimpleX names" privacy toggle + auto-verify on open`
5. `feat(names): set user and channel SimpleX name screens`
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."20ccd40f75e8de375e3274d0fac96766bf16872e" = "0znzlfcr4xzixs3ah2s8p3ckdaiygkxj0zy1lvd44kp97csars2j";
"https://github.com/simplex-chat/simplexmq.git"."97029cf58cc5009d5301ef120f39518680dd004b" = "1b3kqdwqic1b2kmmwc6qfn2yjwrlc6xd9ks48fs5n873f5d5k0dl";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+1 -1
View File
@@ -1405,7 +1405,7 @@ data ChatError
| ChatErrorRemoteHost {rhKey :: RHKey, remoteHostError :: RemoteHostError}
deriving (Show, Exception)
-- why a resolved simplex name could not be used (the name itself resolved; an unregistered name is the agent's NAME NOT_FOUND)
-- why a resolved SimpleX name could not be used (the name itself resolved; an unregistered name is the agent's NAME NOT_FOUND)
data SimplexNameError
= SNENoValidLink -- the name's record has no usable contact/channel link
| SNEUnknownName -- the resolved link's profile has no name, or a different name
+32 -14
View File
@@ -1507,7 +1507,7 @@ processChatCommand cxt nm = \case
unless (nameResolvesTo sl nrSimplexContact) $ throwCmdError "name does not point to your address"
pure $ Just (CLShort sl)
_ -> throwCmdError "create the address short link and add it to name"
let p' = (fromLocalProfile p :: Profile) {simplexName = (`SimplexNameClaim` Nothing) <$> name_, contactLink = cl'}
let p' = (fromLocalProfile p :: Profile) {simplexName = mkSimplexNameClaim name_ Nothing, contactLink = cl'}
updateProfile_ user p' True $ withFastStore $ \db -> do
user' <- updateUserProfile db user p'
liftIO $ setUserSimplexName db user' name_
@@ -4207,17 +4207,30 @@ processChatCommand cxt nm = \case
Nothing -> do
l' <- resolveSLink
(FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l'
contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData)
let linkProfile_ = (\ContactShortLinkData {profile} -> profile) <$> contactSLinkData_
linkName_ = linkProfile_ >>= \Profile {simplexName} -> claimName <$> simplexName
verifiedName_ = case nl of CTName ni -> Just ni; _ -> Nothing
refreshContact ct' = case (verifiedName_, linkProfile_) of
(Just _, Just p) -> updateContactFromLinkData user ct' p
_ -> pure ct'
forM_ verifiedName_ $ \ni -> verifyNameClaim ni linkName_
withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case
Just ct' | not (contactDeleted ct') -> pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'))
Just ct' | not (contactDeleted ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct''))
_ -> do
contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData)
let ContactLinkData _ UserContactData {owners} = cData
ov = verifyLinkOwner rootKey owners l' sig_
plan <- contactRequestPlan user cReq contactSLinkData_ ov
case (nl, plan) of
(CTName ni, CPContactAddress cap@(CAPOk (Just ContactShortLinkData {profile = Profile {simplexName}}) _ _)) -> do
_ <- verifyNameClaim ni (claimName <$> simplexName)
pure (con l' cReq, CPContactAddress cap {verifiedName = Just ni})
case plan of
CPContactAddress cap@(CAPOk {}) -> pure (con l' cReq, CPContactAddress cap {verifiedName = verifiedName_})
CPContactAddress (CAPKnown ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, CPContactAddress (CAPKnown ct''))
CPContactAddress (CAPContactViaAddress ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct''))
_ -> pure (con l' cReq, plan)
where
knownLinkPlans = withFastStore $ \db ->
@@ -4265,14 +4278,19 @@ processChatCommand cxt nm = \case
(Nothing, Nothing) -> pure ()
_ -> throwChatError CEInvalidConnReq
let ov = verifyLinkOwner rootKey owners l' sig_
verifiedName_ = case nl of CTName ni -> Just ni; _ -> Nothing
claimedName GroupProfile {publicGroup} = claimName <$> (publicGroup >>= publicGroupAccess >>= publicGroupClaim)
plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov
case (nl, plan) of
(CTName ni, CPGroupLink glp@(GLPOk (Just _) (Just gld) _ _)) -> do
let GroupShortLinkData {groupProfile = GroupProfile {publicGroup = pg}} = gld
gName = claimName <$> (pg >>= publicGroupAccess >>= publicGroupClaim)
_ <- verifyNameClaim ni gName
pure (con l' cReq, CPGroupLink glp {verifiedName = Just ni})
_ -> pure (con l' cReq, plan)
forM_ verifiedName_ $ \ni ->
verifyNameClaim ni $ case plan of
CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile = gp}) _ _) -> claimedName gp
CPGroupLink (GLPKnown GroupInfo {groupProfile = gp} _ _ _) -> claimedName gp
CPGroupLink (GLPOwnLink GroupInfo {groupProfile = gp}) -> claimedName gp
CPGroupLink (GLPConnectingProhibit (Just GroupInfo {groupProfile = gp})) -> claimedName gp
_ -> maybe Nothing (\GroupShortLinkData {groupProfile = gp} -> claimedName gp) groupSLinkData_
pure $ case plan of
CPGroupLink glp@(GLPOk {}) -> (con l' cReq, CPGroupLink glp {verifiedName = verifiedName_})
_ -> (con l' cReq, plan)
where
unsupportedGroupType = \case
Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel
+13
View File
@@ -1467,6 +1467,19 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = G
Just PublicGroupData {publicMemberCount} -> Just publicMemberCount /= localCount
_ -> False
updateContactFromLinkData :: User -> Contact -> Profile -> CM Contact
updateContactFromLinkData user ct@Contact {contactId, profile = profile@LocalProfile {simplexName = prevClaim, contactDomainVerification}} linkProfile@Profile {simplexName = newClaim}
| profileChanged || verifyChanged = do
cxt <- chatStoreCxt
when profileChanged $ void $ withStore $ \db -> updateContactProfile db cxt user ct linkProfile
when verifyChanged $ withStore' $ \db -> setContactDomainVerified db user contactId True
withStore $ \db -> getContact db cxt user contactId
| otherwise = pure ct
where
profileChanged = fromLocalProfile profile /= linkProfile
claimChanged = (claimName <$> prevClaim) /= (claimName <$> newClaim)
verifyChanged = contactDomainVerification /= Just True || claimChanged
-- TODO [relays] owner: set owners on updating link data (multi-owner)
groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData)
groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}, groupKeys} GroupLink {groupLinkId} groupRelays =
+3 -3
View File
@@ -74,16 +74,16 @@ instance ToField NameClaimProof where toField = toField . encodeJSON
instance FromField NameClaimProof where fromField = fromTextField_ decodeJSON
data SimplexNameClaim = SimplexNameClaim
{ name :: SimplexNameInfo,
{ name :: StrJSON "SimplexNameInfo" SimplexNameInfo,
proof :: Maybe NameClaimProof
}
deriving (Eq, Show)
mkSimplexNameClaim :: Maybe SimplexNameInfo -> Maybe NameClaimProof -> Maybe SimplexNameClaim
mkSimplexNameClaim name_ proof_ = (`SimplexNameClaim` proof_) <$> name_
mkSimplexNameClaim name_ proof_ = (\n -> SimplexNameClaim (StrJSON n) proof_) <$> name_
claimName :: SimplexNameClaim -> SimplexNameInfo
claimName (SimplexNameClaim n _) = n
claimName (SimplexNameClaim n _) = unStrJSON n
claimProof :: SimplexNameClaim -> Maybe NameClaimProof
claimProof (SimplexNameClaim _ p) = p
+1 -1
View File
@@ -393,7 +393,7 @@ setUserSimplexName db user@User {userId, profile = p@LocalProfile {profileId}} n
db
"UPDATE contact_profiles SET simplex_name = ?, updated_at = ? WHERE user_id = ? AND contact_profile_id = ?"
(name_, ts, userId, profileId)
pure (user :: User) {profile = p {simplexName = (`SimplexNameClaim` Nothing) <$> name_}}
pure (user :: User) {profile = p {simplexName = mkSimplexNameClaim name_ Nothing}}
setUserProfileContactLink :: DB.Connection -> User -> Maybe UserContactLink -> IO User
setUserProfileContactLink db user@User {userId, profile = p@LocalProfile {profileId}} ucl_ = do
+12 -13
View File
@@ -1128,17 +1128,13 @@ simplexChatContact' = \case
CLFull (CRContactUri crData) -> CLFull $ CRContactUri crData {crScheme = simplexChat}
l@(CLShort _) -> l
shareLinkStr :: Maybe SimplexNameInfo -> B.ByteString -> B.ByteString
shareLinkStr (Just ni) _ = strEncode ni
shareLinkStr Nothing fallback = fallback
groupDomainName :: GroupInfo -> Maybe SimplexNameInfo
groupDomainName GroupInfo {groupProfile = GroupProfile {publicGroup}} =
claimName <$> (publicGroup >>= publicGroupAccess >>= publicGroupClaim)
viewNameVerified :: Maybe SimplexNameInfo -> Maybe Text -> [StyledString]
viewNameVerified name_ result =
let nameStr = maybe "name" (\ni -> "simplex name " <> shortNameInfoStr ni) name_
let nameStr = maybe "name" (\ni -> "SimpleX name " <> shortNameInfoStr ni) name_
in case result of
Nothing -> [plain nameStr <> " verified"]
Just reason -> [plain nameStr <> " not verified: " <> plain reason]
@@ -1155,7 +1151,7 @@ simplexNameStatus (Just ni) status hasProof = case status of
| hasProof -> [line "unverified"]
| otherwise -> []
where
line s = "simplex name: " <> plain (shortNameInfoStr ni) <> " (" <> s <> ")"
line s = "SimpleX name: " <> plain (shortNameInfoStr ni) <> " (" <> s <> ")"
-- TODO [short links] show all settings
viewAddressSettings :: AddressSettings -> [StyledString]
@@ -1174,12 +1170,14 @@ groupLink_ :: StyledString -> GroupInfo -> GroupLink -> [StyledString]
groupLink_ intro g GroupLink {connLinkContact = CCLink cReq shortLink, acceptMemberRole} =
[ intro,
"",
plain $ shareLinkStr (groupDomainName g) $ maybe cReqStr strEncode shortLink,
"",
"Anybody can connect to you and join group as " <> showRole acceptMemberRole <> " with: " <> highlight' "/c <group_link_above>",
"to show it again: " <> highlight ("/show link #" <> viewGroupName g),
"to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)"
plain $ maybe cReqStr strEncode shortLink
]
<> ["SimpleX name: " <> plain (shortNameInfoStr ni) | Just ni <- [groupDomainName g]]
<> [ "",
"Anybody can connect to you and join group as " <> showRole acceptMemberRole <> " with: " <> highlight' "/c <group_link_above>",
"to show it again: " <> highlight ("/show link #" <> viewGroupName g),
"to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)"
]
<> ["The group link for old clients: " <> plain cReqStr | isJust shortLink]
where
cReqStr = strEncode $ simplexChatContact cReq
@@ -1253,8 +1251,9 @@ viewGroupLinkRelaysUpdated g groupLink relays =
<> map showRelay relays
<>
[ "group link:",
plain $ shareLinkStr (groupDomainName g) $ maybe cReqStr strEncode shortLink
plain $ maybe cReqStr strEncode shortLink
]
<> ["SimpleX name: " <> plain (shortNameInfoStr ni) | Just ni <- [groupDomainName g]]
where
GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink
cReqStr = strEncode $ simplexChatContact cReq
@@ -2686,7 +2685,7 @@ viewChatError isCmd logLevel testView = \case
let reason = case nameErr of
SNENoValidLink -> "has no usable connection link"
SNEUnknownName -> "is not included in the connection link's profile"
in ["simplex name " <> plain (shortNameInfoStr ni) <> " " <> reason]
in ["SimpleX name " <> plain (shortNameInfoStr ni) <> " " <> reason]
CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr]
CEInvalidChatMessage Connection {connId} msgMeta_ msg e ->
[ plain $
+28 -5
View File
@@ -17,6 +17,7 @@ chatNamesTests :: SpecWith TestParams
chatNamesTests = do
it "connect by resolved name" testConnectByName
it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed
it "connect by name to a known contact not claimed in profile is rejected" testConnectByNameKnownContactNotClaimed
it "connect by unregistered name fails to resolve" testConnectByNameNotFound
it "set name not resolving to own address is rejected" testSetNameNotOwnAddress
it "connect by channel name" testConnectByChannelName
@@ -48,7 +49,7 @@ testConnectByName ps = withSmpServerAndNames $ \reg ->
bob <## "receiving messages via: localhost"
bob <## "sending messages via: localhost"
_ <- getTermLine bob
bob <## "simplex name: @alice.simplex (verified)"
bob <## "SimpleX name: @alice.simplex (verified)"
bob <## "you've shared main profile with this contact"
bob <## "connection not verified, use /code command to see security code"
bob <## "quantum resistant end-to-end encryption"
@@ -65,7 +66,29 @@ testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg ->
(shortLink, _) <- getContactLinks alice True
registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink))
bob ##> "/c @alice.simplex"
bob <## "simplex name @alice.simplex is not included in the connection link's profile"
bob <## "SimpleX name @alice.simplex is not included in the connection link's profile"
testConnectByNameKnownContactNotClaimed :: HasCallStack => TestParams -> IO ()
testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg ->
testChat2 aliceProfile bobProfile (test reg) ps
where
aliceName = SimplexNameInfo NTContact (SimplexNameDomain TLDSimplex "alice" [])
test reg alice bob = do
alice ##> "/ad"
(shortLink, _) <- getContactLinks alice True
bob ##> ("/c " <> shortLink)
bob <## "connection request sent!"
alice <## "bob (Bob) wants to connect to you!"
alice <## "to accept: /ac bob"
alice <## "to reject: /rc bob (the sender will NOT be notified)"
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink))
bob ##> "/c @alice.simplex"
bob <## "SimpleX name @alice.simplex is not included in the connection link's profile"
testConnectByNameNotFound :: HasCallStack => TestParams -> IO ()
testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg ->
@@ -97,9 +120,9 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg ->
(shortLink, _) <- prepareChannel1Relay "team" alice cath
registerName reg teamName (channelNameRecord "team" (T.pack shortLink))
alice ##> "/public group access #team domain=team.simplex"
alice <## "updated public group access: domain=simplex:/name#team.simplex"
alice <## "updated public group access: domain=#team.simplex"
cath <## "alice updated group #team: (signed)"
cath <## "updated public group access: domain=simplex:/name#team.simplex"
cath <## "updated public group access: domain=#team.simplex"
bob ##> "/c #team.simplex"
bob <## "#team: connection started"
concurrentlyN_
@@ -114,7 +137,7 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg ->
]
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "group link: known group #team"
bob <## "simplex name: #team (verified)"
bob <## "SimpleX name: #team (verified)"
bob <## "use #team <message> to send messages"
where
teamName = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" [])
-14
View File
@@ -4,25 +4,11 @@ module ViewTests where
import Data.Time
import Simplex.Chat.View
import Simplex.Messaging.Agent.Protocol (SimplexNameDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..))
import Test.Hspec
viewTests :: Spec
viewTests = do
testRecent
testShareLinkStr
testShareLinkStr :: Spec
testShareLinkStr = describe "shareLinkStr" $ do
let alice = SimplexNameInfo NTContact (SimplexNameDomain TLDSimplex "alice" [])
grp = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" [])
fallback = "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2Fexample"
it "uses simplex:/name URI for a contact's simplex name" $
shareLinkStr (Just alice) fallback `shouldBe` "simplex:/name@alice.simplex"
it "uses simplex:/name URI for a public group simplex name" $
shareLinkStr (Just grp) fallback `shouldBe` "simplex:/name#team.simplex"
it "falls back to the raw connection link when simplexName is Nothing" $
shareLinkStr Nothing fallback `shouldBe` fallback
testRecent :: Spec
testRecent = describe "recent" $ do