From 9bd38c4aec7690cbe560be37759d44b85e86432e Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:26:04 +0400 Subject: [PATCH] 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 Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 32 ++- apps/ios/Shared/Model/SimpleXAPI.swift | 47 ++++ apps/ios/Shared/Views/Chat/ChatInfoView.swift | 84 ++++++ .../Views/Chat/ChatItem/MsgContentView.swift | 7 +- .../Chat/Group/ChannelWebAccessView.swift | 2 +- .../Views/Chat/Group/GroupChatInfoView.swift | 48 ++++ .../Shared/Views/ChatList/ChatListView.swift | 17 +- .../Views/NewChat/NewChatMenuButton.swift | 15 +- .../Shared/Views/NewChat/NewChatView.swift | 50 ++-- .../NetworkAndServers/NetworkAndServers.swift | 33 ++- .../NetworkAndServers/OperatorView.swift | 18 +- .../ProtocolServersView.swift | 14 +- .../Views/UserSettings/PrivacySettings.swift | 4 + .../Views/UserSettings/SettingsView.swift | 2 + .../Views/UserSettings/UserAddressView.swift | 82 ++++++ apps/ios/SimpleXChat/APITypes.swift | 9 + apps/ios/SimpleXChat/ChatTypes.swift | 85 +++++- .../chat/simplex/common/model/ChatModel.kt | 48 +++- .../chat/simplex/common/model/SimpleXAPI.kt | 135 +++++++++- .../simplex/common/views/chat/ChatInfoView.kt | 15 ++ .../common/views/chat/SimplexNameView.kt | 95 +++++++ .../views/chat/group/ChannelWebPageView.kt | 2 +- .../views/chat/group/GroupChatInfoView.kt | 46 ++++ .../common/views/chat/item/TextItemView.kt | 11 +- .../common/views/chatlist/ChatListView.kt | 15 +- .../common/views/newchat/ConnectPlan.kt | 20 +- .../common/views/newchat/NewChatSheet.kt | 15 +- .../common/views/newchat/NewChatView.kt | 22 +- .../views/usersettings/PrivacySettings.kt | 5 + .../views/usersettings/SetSimplexNameView.kt | 75 ++++++ .../views/usersettings/UserAddressView.kt | 28 ++ .../networkAndServers/NetworkAndServers.kt | 35 +-- .../networkAndServers/OperatorView.kt | 46 +++- .../networkAndServers/ProtocolServersView.kt | 10 +- .../commonMain/resources/MR/base/strings.xml | 18 +- cabal.project | 2 +- plans/2026-06-27-namespace-ui-display-set.md | 243 ++++++++++++++++++ scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 46 +++- src/Simplex/Chat/Library/Internal.hs | 13 + src/Simplex/Chat/Names.hs | 6 +- src/Simplex/Chat/Store/Profiles.hs | 2 +- src/Simplex/Chat/View.hs | 25 +- tests/ChatTests/Names.hs | 33 ++- tests/ViewTests.hs | 14 - 46 files changed, 1371 insertions(+), 207 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt create mode 100644 plans/2026-06-27-namespace-ui-display-set.md diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index a5a56174b1..2158bc5e4e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -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 { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ea2de31569..c18711a979 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1083,6 +1083,24 @@ private func apiConnectResponseAlert(_ r: APIResult) -> 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? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index fdd1dc8a6a..1630d68eac 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -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( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 11c3c4c3f4..6b15053cac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift index dd46b7a117..247144da04 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelWebAccessView.swift @@ -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 ) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 41e24a6ced..f6e07221af 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -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) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d90149c7dd..4d3a972dfa 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -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 diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index f99b03086e..32848bb219 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -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 diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 67fd353ebc..4e4d8bb4f7 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 24cf088918..8095c0297e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -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]> { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 26f24f2f0f..4e2f1992d6 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -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) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b059be7cb0..a92491edef 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -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) + } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index ad6b2d4454..529833fc65 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -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) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 135a90c65e..d30415967b 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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, diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index e22042fa24..5d1263f383 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -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() diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5f1d8ef6c2..a03b696561 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -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 { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7a20a8d4ba..581376d8f1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 44361baa73..5ef3e1625b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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, 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 { } } +@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 -) +) { + // 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"), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 02353e2c8a..217964326a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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? { + 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? { + 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, 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 diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index a099bf333b..d9b430d07d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -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 + } + } + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt new file mode 100644 index 0000000000..07e003f93d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -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? +) { + 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) } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt index 262a78b3c8..30595b8c5c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -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 ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 2b1c7bcd09..00aaf28184 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -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 @@ -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) } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index c9f7d96f39..c7c96cd731 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 3012525f9b..99dec506e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index e5dbe01d68..9ed6dd0f19 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -31,11 +31,6 @@ suspend fun planAndConnect( filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { 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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 68f42f4186..41df280a8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 6799fa1300..d3bca178aa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -679,7 +679,11 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, 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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 0b698b2c5d..2a13a3995d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt new file mode 100644 index 0000000000..b2f157644c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -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() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 36c74cceb6..eb2ef42b29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -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)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 892a252a9d..73204b42ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -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): String? { - for (err in serverErrors) { - if (err.globalError != null) { - return err.globalError - } - } - return null -} +fun globalServersErrors(serverErrors: List): List = + serverErrors.mapNotNull { it.globalError } -fun globalServersWarning(serverWarnings: List): String? { - for (warn in serverWarnings) { - if (warn.globalWarning != null) { - return warn.globalWarning - } - } - return null -} +fun globalServersWarnings(serverWarnings: List): List = + serverWarnings.mapNotNull { it.globalWarning } fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index f5bceabbf1..53277c9ccd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -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) ) ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 280cd7bedb..63365bd080 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -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() diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 9262bd9dac..9ff7996770 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -146,6 +146,7 @@ For chat profile %s: Errors in servers configuration. No chat relays enabled. + No servers to resolve names. Server warning Error accepting conditions Spam @@ -199,6 +200,12 @@ Connecting via channel name requires a newer app version. Connecting via contact name requires a newer app version. Please upgrade the app. + SimpleX name not found + There is no contact or group registered with this SimpleX name. + Cannot reconnect via name + This SimpleX name is known but has no saved link to reconnect via. + Name resolution unavailable + None of your SMP servers support resolving SimpleX names. Add a server that does, or use a connection link. Channel temporarily unavailable Channel has no active relays. Please try to join later. App update required @@ -929,7 +936,15 @@ Paste link One-time invitation link 1-time link - SimpleX address + SimpleX address and name + Verify name + Verify SimpleX names + SimpleX name not verified + Set SimpleX name + name.simplex + Error saving SimpleX name + 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. + Set a SimpleX name so people can find this channel as #name. The name must be registered to this channel\'s address. Or show this code Full link Short link @@ -2153,6 +2168,7 @@ Use for messages To receive For private routing + To resolve names Added message servers Use for files To send diff --git a/cabal.project b/cabal.project index 0d26533421..eb654ddd23 100644 --- a/cabal.project +++ b/cabal.project @@ -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 diff --git a/plans/2026-06-27-namespace-ui-display-set.md b/plans/2026-06-27-namespace-ui-display-set.md new file mode 100644 index 0000000000..1f01cc4311 --- /dev/null +++ b/plans/2026-06-27-namespace-ui-display-set.md @@ -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 @`. + - `APIVerifyPublicGroupName {groupId}` -> `/_verify name #`. + - 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 []` (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 # `, 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` diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 88123b99ce..f929423613 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -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"; diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c018228b6f..289fa90e7b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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 diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9ab1d38193..b7896c385f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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 diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index a012885a09..a958abac54 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -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 = diff --git a/src/Simplex/Chat/Names.hs b/src/Simplex/Chat/Names.hs index dd35a9d5ca..29eb70f1ed 100644 --- a/src/Simplex/Chat/Names.hs +++ b/src/Simplex/Chat/Names.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 0e22c91e44..1cd494d5d7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -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 diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6d599ce128..c4f5dd6092 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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 ", - "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 ", + "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 $ diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 4d024af7e9..3f688c7f0c 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -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 to send messages" where teamName = SimplexNameInfo NTPublicGroup (SimplexNameDomain TLDSimplex "team" []) diff --git a/tests/ViewTests.hs b/tests/ViewTests.hs index 8c34ebb5e2..085a56af44 100644 --- a/tests/ViewTests.hs +++ b/tests/ViewTests.hs @@ -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