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