From 7306f3aeefb7e0fb0ec7cd3c79e73fc28e2fd0dc Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 13 Oct 2026 17:05:15 +0100 Subject: [PATCH] core: alerts with information about registering names (#7653) * minimal api change * embed status. reduce * drop fake owner change * no-valid-link fix * add UX flow for name lookups * diagram edits * corrections * update name lookup API * manual renaming * implement core + cli * implement UI * fix * build iOS app blindly * shrink diff * fix review comments * review iterations * opuse 5.5 /goal iterations * fixes EP review * fixes * fixes * short goal iterations * refactor core-UI API to NameWarning with logic in core * diff cosmetics * simplify and normalize diagram * add excalidraw normalizer script and test git hook * cosmetics * commit wireframe collab doc to repo as README * review round on UX coverage * iterate on /goal * iterate on /goal * add tests * iterate on /goal * fix freshness bug with local chats: When core marks a chat verified for a name, it now first un-verifies the user's other chats of the same kind: - a contact name covers contacts and business chats; - a channel name covers channels. * align app divergences * docs fixes * iterations on /goal * align excalidraw * align and simplify core/UI/UXcanvas * 27 rounds of /goal * review * core: simplify connect plan with name lookup * simplify * cache names in a separate table * cache names resolution * wip * all * fix * remove cache * update plans * simplify * bot types * change types * simplify * update bot types * remove review * update view, tests * simplify "unverify" * update query plan * changes * simplify * test helpers * simpler * update query plans * fix * query plans * query plans * fixes * query plans --------- Co-authored-by: Alain Brenzikofer Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- apps/ios/Shared/Model/AppAPITypes.swift | 47 +- apps/ios/Shared/Model/ChatModel.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 19 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 12 +- .../Shared/Views/ChatList/ChatListView.swift | 55 +- .../ios/Shared/Views/Helpers/ShareSheet.swift | 16 + .../Shared/Views/NewChat/NewChatView.swift | 308 +++++---- apps/ios/SimpleXChat/ChatTypes.swift | 27 +- .../chat/simplex/common/model/ChatModel.kt | 15 +- .../chat/simplex/common/model/SimpleXAPI.kt | 70 +- .../common/views/chat/SimplexNameView.kt | 16 +- .../common/views/chatlist/ChatListView.kt | 53 +- .../common/views/newchat/ConnectPlan.kt | 114 +++- .../moko-resources/base/strings.xml | 16 + .../multiplatform/product/flows/connection.md | 21 +- .../src/Directory/Service.hs | 10 +- .../src/Directory/Store.hs | 2 +- bots/api/TYPES.md | 70 +- bots/src/API/Docs/Events.hs | 1 + bots/src/API/Docs/Types.hs | 8 + bots/src/API/TypeInfo.hs | 2 + .../types/typescript/src/types.ts | 82 ++- .../src/simplex_chat/types/_types.py | 60 +- plans/2026-09-22-name-lookup-core-api.md | 80 +++ plans/2026-09-28-name-warnings.md | 325 +++++++++ .../2026-09-18-names-lookup-flows.excalidraw | 328 +++++++++ plans/sketches/README.md | 97 +++ plans/sketches/normalize-excalidraw.py | 70 ++ src/Simplex/Chat/Controller.hs | 45 +- src/Simplex/Chat/Library/Commands.hs | 236 +++++-- src/Simplex/Chat/Library/Internal.hs | 50 +- src/Simplex/Chat/Store/Direct.hs | 20 +- src/Simplex/Chat/Store/Groups.hs | 30 +- .../SQLite/Migrations/chat_query_plans.txt | 42 +- src/Simplex/Chat/Store/Shared.hs | 10 +- src/Simplex/Chat/Types.hs | 18 +- src/Simplex/Chat/View.hs | 57 +- tests/ChatTests/Names.hs | 646 +++++++++++++++++- tests/NameResolver.hs | 56 +- 39 files changed, 2751 insertions(+), 385 deletions(-) create mode 100644 plans/2026-09-22-name-lookup-core-api.md create mode 100644 plans/2026-09-28-name-warnings.md create mode 100644 plans/sketches/2026-09-18-names-lookup-flows.excalidraw create mode 100644 plans/sketches/README.md create mode 100644 plans/sketches/normalize-excalidraw.py diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 6d22acc232..5fadacc666 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1180,6 +1180,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case contactSndReady(user: UserRef, contact: Contact) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest, chat_: ChatData?) case contactUpdated(user: UserRef, toContact: Contact) + case nameMoved(user: UserRef, contactIds: [Int64], groupIds: [Int64]) case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) case subscriptionStatus(subscriptionStatus: SubscriptionStatus, connections: [String]) case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo) @@ -1263,6 +1264,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactSndReady: "contactSndReady" case .receivedContactRequest: "receivedContactRequest" case .contactUpdated: "contactUpdated" + case .nameMoved: "nameMoved" case .groupMemberUpdated: "groupMemberUpdated" case .subscriptionStatus: "subscriptionStatus" case .chatInfoUpdated: "chatInfoUpdated" @@ -1340,6 +1342,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactSndReady(u, contact): return withUser(u, String(describing: contact)) case let .receivedContactRequest(u, contactRequest, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))") case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .nameMoved(u, contactIds, groupIds): return withUser(u, "contactIds: \(contactIds)\ngroupIds: \(groupIds)") case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") case let .subscriptionStatus(status, conns): return "subscriptionStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo)) @@ -1462,9 +1465,49 @@ enum PlanResolveMode: String { enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) - case contactAddress(contactAddressPlan: ContactAddressPlan) - case groupLink(groupLinkPlan: GroupLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan, nameChange: NameChange?) + case groupLink(groupLinkPlan: GroupLinkPlan, nameChange: NameChange?) case error(chatError: ChatError) + + var localChats: [ChatInfo] { + [planChat, knownChat].compactMap { $0 } + } + + var planChat: ChatInfo? { + switch self { + case let .contactAddress(.connectingProhibit(contact), _), let .contactAddress(.known(contact), _), let .contactAddress(.contactViaAddress(contact), _): + .direct(contact: contact) + case let .groupLink(.ownLink(groupInfo), _), let .groupLink(.known(groupInfo), _), let .groupLink(.connectingProhibit(.some(groupInfo)), _): + .group(groupInfo: groupInfo, groupChatScope: nil) + default: nil + } + } + + var nameChange: NameChange? { + switch self { + case let .contactAddress(_, nameChange), let .groupLink(_, nameChange): nameChange + default: nil + } + } + + var knownChat: ChatInfo? { + switch nameChange { + case let .some(.moved(chat)): chat + default: nil + } + } + + var isOwnLink: Bool { + switch self { + case .contactAddress(.ownLink, _), .groupLink(.ownLink, _): true + default: false + } + } +} + +enum NameChange: Decodable, Hashable { + case lapsed(nameWarning: NameWarning) + case moved(knownChat: ChatInfo) } enum InvitationLinkPlan: Decodable, Hashable { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1ee79e1c4f..704821d088 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -652,7 +652,7 @@ final class ChatModel: ObservableObject { updateChat(.group(groupInfo: groupInfo, groupChatScope: nil)) } - private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { + func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { if hasChat(cInfo.id) { updateChatInfo(cInfo) } else if addMissing { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3ec9c34f8e..5e38a77ea2 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1104,6 +1104,8 @@ private func apiConnectResponseAlert(_ r: APIResult) async { NSLocalizedString("Unconfirmed name", comment: ""), message: String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner.", comment: ""), domain.fullDomainName) ) + case let .nameWarning(warning): + showNameWarningAlert(domain: domain, warning: warning, own: false, openExistingChat: nil, cleanup: nil) } case .errorAgent(.NO_NAME_SERVERS): showAlert( @@ -2605,6 +2607,23 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateChatInfo(cInfo) } } + case let .nameMoved(user, contactIds, groupIds): + if active(user) { + await MainActor.run { + for contactId in contactIds { + if case var .direct(contact)? = m.getContactChat(contactId)?.chatInfo { + contact.profile.contactDomainVerified = .moved + m.updateChatInfo(.direct(contact: contact)) + } + } + for groupId in groupIds { + if case var .group(groupInfo, _)? = m.getGroupChat(groupId)?.chatInfo { + groupInfo.groupDomainVerified = .moved + m.updateChatInfo(.group(groupInfo: groupInfo, groupChatScope: nil)) + } + } + } + } case let .groupMemberUpdated(user, groupInfo, _, toMember): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 3d1e7ef477..094c2d70f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -1412,8 +1412,8 @@ struct SimplexNameView: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES) var autoVerify = false let simplexName: String - let verified: Bool? - let verify: () async -> (Bool?, String?)? + let verified: DomainVerification? + let verify: () async -> (DomainVerification?, String?)? var verifiable: Bool = true @State private var inFlight = false @State private var showSpinner = false @@ -1427,7 +1427,7 @@ struct SimplexNameView: View { private var nameText: Text { Text(simplexName) .font(.subheadline) - .foregroundColor(verified == true ? theme.colors.primary : theme.colors.secondary) + .foregroundColor(verified == .verified ? theme.colors.primary : theme.colors.secondary) } // Size the inline check/cross to the name's cap height so it reads like a capital letter, not an oversized glyph. @@ -1439,7 +1439,7 @@ struct SimplexNameView: View { nameText ProgressView() } - } else if verified == true { + } else if verified == .verified { HStack(alignment: .firstTextBaseline, spacing: 4) { nameText Image(systemName: "checkmark").font(iconFont).foregroundColor(theme.colors.primary) @@ -1452,10 +1452,10 @@ struct SimplexNameView: View { } } else if !verifiable { nameText - } else if verified == false { + } else if verified == .failed || verified == .moved { HStack(alignment: .firstTextBaseline, spacing: 4) { nameText - Image(systemName: "xmark").font(iconFont).foregroundColor(.red) + Image(systemName: "xmark").font(iconFont).foregroundColor(verified == .failed ? .red : theme.colors.secondary) .alignmentGuide(.firstTextBaseline) { $0[.bottom] - $0.height * 0.15 } } .contentShape(Rectangle()) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 5c5021b79f..43b02d70ec 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -775,7 +775,8 @@ struct ChatListSearchBar: View { searchText: $searchText, connectNameCandidate: $connectNameCandidate, searchFocussed: $searchFocussed, - dismiss: false + dismiss: false, + showLocalChats: showLocalChats ) } else { ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } @@ -820,7 +821,8 @@ struct ChatListSearchBar: View { searchText: $searchText, connectNameCandidate: $connectNameCandidate, searchFocussed: $searchFocussed, - dismiss: false + dismiss: false, + showLocalChats: showLocalChats ) } } @@ -860,15 +862,14 @@ struct ChatListSearchBar: View { if Task.isCancelled { return } // a bare name can be a contact or a channel: search both and keep every match let targets = candidate.hasPrefix("@") || candidate.hasPrefix("#") ? [candidate] : ["@\(candidate)", "#\(candidate)"] - var ids: [String] = [] + var localChats: [ChatInfo] = [] for name in targets { let plan = await apiConnectPlan(connLink: name, resolveMode: .never, inProgress: BoxedValue(false)) if Task.isCancelled { return } - if let id = knownChatId(plan) { ids.append(id) } + localChats += plan?.connectionPlan.localChats ?? [] } - searchChatFilteredBySimplexLink = Set(ids) - // drop the row only when every searched type is already known locally - if ids.count == targets.count { connectNameCandidate = nil } + for cInfo in localChats { ChatModel.shared.updateChat(cInfo) } + searchChatFilteredBySimplexLink = Set(localChats.map { $0.id }) } } else if t != "" { searchFocussed = true @@ -916,11 +917,15 @@ struct ChatListSearchBar: View { filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] } ) } + + private func showLocalChats(_ chats: [ChatInfo]) { + searchChatFilteredBySimplexLink.formUnion(chats.map { $0.id }) + } } // Row shown when the search text is a SimpleX name — in place of the list tags in the chat list, below // the search field in the new chat sheet. The @ icon marks a contact name, the tag icon a channel/other -// name; tapping hides the keyboard, connects online, and clears the field. +// name; tapping hides the keyboard, connects online, and clears the field when a chat opens or a connection starts. struct ConnectByNameRow: View { @EnvironmentObject var theme: AppTheme var name: String @@ -928,6 +933,7 @@ struct ConnectByNameRow: View { @Binding var connectNameCandidate: String? @FocusState.Binding var searchFocussed: Bool var dismiss: Bool + var showLocalChats: (([ChatInfo]) -> Void)? = nil var body: some View { HStack(spacing: 4) { @@ -945,7 +951,8 @@ struct ConnectByNameRow: View { name, theme: theme, dismiss: dismiss, - cleanup: { + showLocalChats: showLocalChats, + onOpen: { searchText = "" connectNameCandidate = nil } @@ -965,36 +972,6 @@ private func isNameLabel(_ s: String) -> Bool { } // On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it. -// The chat id a local (.never) search resolved to — a contact, business, or channel — or nil on a miss. -// A name-resolved chat may be prepared in the store but not yet listed, so add it so the filter can surface it. -@MainActor -func knownChatId(_ result: ConnectionPlanResult?) -> String? { - guard let plan = result?.connectionPlan else { return nil } - let m = ChatModel.shared - switch plan { - case let .contactAddress(contactAddressPlan): - if case let .known(contact) = contactAddressPlan { - if m.getContactChat(contact.contactId) == nil { - m.addChat(Chat(chatInfo: .direct(contact: contact), chatItems: [])) - } - return contact.id - } - return nil - case let .groupLink(groupLinkPlan): - switch groupLinkPlan { - case .known(let groupInfo), .ownLink(let groupInfo): - if m.getGroupChat(groupInfo.groupId) == nil { - m.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: [])) - } - return groupInfo.id - default: - return nil - } - default: - return nil - } -} - // Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then // dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns // the string to send (keeping @/# so the type is preserved), or nil when the text is not a name. diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index ebe74b2a50..e20f081cdb 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -140,6 +140,7 @@ class OpenChatAlertViewController: UIViewController { private let profileFullName: String private let profileImage: UIView private let profileBadge: LocalBadge? + private let nameCaption: String? private let subtitle: String? private let information: String? private let secondaryInformation: Bool @@ -155,6 +156,7 @@ class OpenChatAlertViewController: UIViewController { profileFullName: String, profileImage: UIView, profileBadge: LocalBadge? = nil, + nameCaption: String? = nil, subtitle: String? = nil, information: String? = nil, secondaryInformation: Bool = false, @@ -169,6 +171,7 @@ class OpenChatAlertViewController: UIViewController { self.profileFullName = profileFullName self.profileImage = profileImage self.profileBadge = profileBadge + self.nameCaption = nameCaption self.subtitle = subtitle self.information = information self.secondaryInformation = secondaryInformation @@ -222,6 +225,17 @@ class OpenChatAlertViewController: UIViewController { var profileViews = [profileImage, nameLabel] + if let nameCaption { + let nameCaptionLabel = UILabel() + nameCaptionLabel.text = nameCaption + nameCaptionLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) + nameCaptionLabel.textColor = .secondaryLabel + nameCaptionLabel.numberOfLines = 1 + nameCaptionLabel.textAlignment = .center + nameCaptionLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(nameCaptionLabel) + } + // Full name label if !profileFullName.isEmpty && profileFullName != profileName { let fullNameLabel = UILabel() @@ -426,6 +440,7 @@ func showOpenChatAlert( profileFullName: String, profileImage: Content, profileBadge: LocalBadge? = nil, + nameCaption: String? = nil, theme: AppTheme, subtitle: String? = nil, information: String? = nil, @@ -448,6 +463,7 @@ func showOpenChatAlert( profileFullName: profileFullName, profileImage: hostedView, profileBadge: profileBadge, + nameCaption: nameCaption, subtitle: subtitle, information: information, secondaryInformation: secondaryInformation, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..52b4d466de 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -974,8 +974,10 @@ private func showAskCurrentOrIncognitoProfileSheet( connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, ownerVerification: OwnerVerification? = nil, + connectOther: (title: String, action: () -> Void)? = nil, dismiss: Bool, - cleanup: (() -> Void)? + cleanup: (() -> Void)?, + onOpen: (() -> Void)? ) { showSheet( title, @@ -985,16 +987,17 @@ private func showAskCurrentOrIncognitoProfileSheet( title: NSLocalizedString("Use current profile", comment: "new chat action"), style: actionStyle, handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup, onOpen: onOpen) } ), UIAlertAction( title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), style: actionStyle, handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup, onOpen: onOpen) } ), + connectOther.map { c in UIAlertAction(title: c.title, style: .default) { _ in c.action() } }, UIAlertAction( title: NSLocalizedString("Cancel", comment: "new chat action"), style: .default, @@ -1002,43 +1005,7 @@ private func showAskCurrentOrIncognitoProfileSheet( cleanup?() } ) - ]} - ) -} - -private func showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet( - contact: Contact, - dismiss: Bool, - cleanup: (() -> Void)? -) { - showSheet( - String.localizedStringWithFormat( - NSLocalizedString("Connect with %@", comment: "new chat action"), - contact.chatViewName - ), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Use current profile", comment: "new chat action"), - style: .default, - handler: { _ in - connectContactViaAddress_(contact, dismiss: dismiss, incognito: false, cleanup: cleanup) - } - ), - UIAlertAction( - title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), - style: .default, - handler: { _ in - connectContactViaAddress_(contact, dismiss: dismiss, incognito: true, cleanup: cleanup) - } - ), - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "new chat action"), - style: .default, - handler: { _ in - cleanup?() - } - ) - ]} + ].compactMap { $0 }} ) } @@ -1046,23 +1013,28 @@ private func showOwnGroupLinkConfirmConnectSheet( groupInfo: GroupInfo, connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, + planSimplexName: SimplexNameInfo? = nil, + connectOther: (title: String, action: () -> Void)? = nil, dismiss: Bool, - cleanup: (() -> Void)? + cleanup: (() -> Void)?, + onOpen: (() -> Void)? ) { if groupInfo.useRelays { showSheet( - String.localizedStringWithFormat( + NSLocalizedString("Your channel", comment: "new chat action"), + message: String.localizedStringWithFormat( NSLocalizedString("This is your link for channel %@!", comment: "new chat action"), - groupInfo.displayName + groupInfo.displayName + (planSimplexName.map { " (\($0.shortStr))" } ?? "") ), actions: {[ UIAlertAction( title: NSLocalizedString("Open channel", comment: "new chat action"), style: .default, handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } ), + connectOther.map { c in UIAlertAction(title: c.title, style: .default) { _ in c.action() } }, UIAlertAction( title: NSLocalizedString("Cancel", comment: "new chat action"), style: .default, @@ -1070,7 +1042,7 @@ private func showOwnGroupLinkConfirmConnectSheet( cleanup?() } ) - ]} + ].compactMap { $0 }} ) } else { showSheet( @@ -1083,21 +1055,21 @@ private func showOwnGroupLinkConfirmConnectSheet( title: NSLocalizedString("Open group", comment: "new chat action"), style: .default, handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } ), UIAlertAction( title: NSLocalizedString("Use current profile", comment: "new chat action"), style: .destructive, handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup, onOpen: onOpen) } ), UIAlertAction( title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), style: .destructive, handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup, onOpen: onOpen) } ), UIAlertAction( @@ -1116,12 +1088,13 @@ private func showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, ownerVerification: OwnerVerification? = nil, - verifiedDomain: SimplexDomain? = nil, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, connectOtherLink: String? = nil, theme: AppTheme, dismiss: Bool, - cleanup: (() -> Void)? + cleanup: (() -> Void)?, + onOpen: (() -> Void)? ) { showOpenChatAlert( profileName: contactShortLinkData.profile.displayName, @@ -1137,6 +1110,7 @@ private func showPrepareContactAlert( size: alertProfileImageSize ), profileBadge: contactShortLinkData.localBadge, + nameCaption: planSimplexName?.shortStr, theme: theme, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), @@ -1146,10 +1120,10 @@ private func showPrepareContactAlert( onConfirm: { Task { do { - let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain) + let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData, verifiedDomain: planSimplexName?.nameDomain) await MainActor.run { ChatModel.shared.addChat(Chat(chat)) - openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) + openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } catch let error { logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)") @@ -1161,7 +1135,7 @@ private func showPrepareContactAlert( } }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } + { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } ) } @@ -1171,12 +1145,13 @@ private func showPrepareGroupAlert( groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, ownerVerification: OwnerVerification? = nil, - verifiedDomain: SimplexDomain? = nil, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, connectOtherLink: String? = nil, theme: AppTheme, dismiss: Bool, - cleanup: (() -> Void)? + cleanup: (() -> Void)?, + onOpen: (() -> Void)? ) { let isChannel = !(groupShortLinkInfo?.direct ?? true) let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } @@ -1191,6 +1166,7 @@ private func showPrepareGroupAlert( : "person.2.circle.fill", size: alertProfileImageSize ), + nameCaption: planSimplexName?.shortStr, theme: theme, subtitle: isChannel ? subscriberCount : nil, information: ownerVerificationMessage(ownerVerification), @@ -1203,14 +1179,14 @@ private func showPrepareGroupAlert( onConfirm: { Task { do { - let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain) + let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData, verifiedDomain: planSimplexName?.nameDomain) await MainActor.run { if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, case let .group(gInfo, _) = chat.chatInfo { ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays } ChatModel.shared.addChat(Chat(chat)) - openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) + openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } catch let error { logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") @@ -1222,7 +1198,7 @@ private func showPrepareGroupAlert( } }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } + { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } ) } @@ -1231,8 +1207,10 @@ private func showOpenKnownContactAlert( _ contact: Contact, theme: AppTheme, dismiss: Bool, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, - connectOtherLink: String? = nil + connectOtherLink: String? = nil, + onOpen: (() -> Void)? ) { showOpenChatAlert( profileName: contact.profile.displayName, @@ -1244,6 +1222,7 @@ private func showOpenKnownContactAlert( size: alertProfileImageSize ), profileBadge: contact.active ? contact.profile.localBadge : nil, + nameCaption: planSimplexName?.shortStr, theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: @@ -1252,10 +1231,10 @@ private func showOpenKnownContactAlert( : NSLocalizedString("Open chat", comment: "new chat action"), secondTitle: connectOtherButton, onConfirm: { - openKnownContact(contact, dismiss: dismiss, cleanup: nil) + openKnownContact(contact, dismiss: dismiss, cleanup: nil, onOpen: onOpen) }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss) } + { planAndConnect(link, theme: theme, dismiss: dismiss, onOpen: onOpen) } } ) } @@ -1278,8 +1257,10 @@ private func showOpenKnownGroupAlert( _ groupInfo: GroupInfo, theme: AppTheme, dismiss: Bool, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, - connectOtherLink: String? = nil + connectOtherLink: String? = nil, + onOpen: (() -> Void)? ) { let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" } showOpenChatAlert( @@ -1291,6 +1272,7 @@ private func showOpenKnownGroupAlert( iconName: groupInfo.chatIconName, size: alertProfileImageSize ), + nameCaption: planSimplexName?.shortStr, theme: theme, subtitle: groupInfo.useRelays ? subscriberCount : nil, information: groupInfo.nextConnectPrepared || groupInfo.businessChat != nil @@ -1309,14 +1291,75 @@ private func showOpenKnownGroupAlert( ), secondTitle: connectOtherButton, onConfirm: { - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil, onOpen: onOpen) }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss) } + { planAndConnect(link, theme: theme, dismiss: dismiss, onOpen: onOpen) } } ) } +private func namePrice(_ price: NamePrice) -> String { + let dollars = "$\(price.amount / 100)" + (price.amount % 100 == 0 ? "" : String(format: ".%02lld", price.amount % 100)) + return String.localizedStringWithFormat(NSLocalizedString("%1$@ for %2$d years", comment: "name price"), dollars, price.years) +} + +func showNameWarningAlert( + domain: SimplexDomain, + warning: NameWarning, + own: Bool, + openExistingChat: (() -> Void)?, + cleanup: (() -> Void)? +) { + let nameStr = domain.fullDomainName + func alert(_ title: String, _ message: String, action: (title: String, handler: () -> Void)? = nil) { + showAlert(title, message: message, actions: {[ + action.map { action in UIAlertAction(title: action.title, style: .default) { _ in action.handler(); cleanup?() } }, + openExistingChat.map { open in UIAlertAction(title: NSLocalizedString("Open chat", comment: "new chat action"), style: .default) { _ in open() } }, + okCleanupAlertAction(cleanup: cleanup) + ].compactMap { $0 }}) + } + let openHowTo = { openBrowserAlert(uri: "https://simplex.domains/#testing") } + switch warning { + case let .expired(expiredAt, graceUntil) where !own: + let message = if let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("%1$@ expired on %2$@. Its owner can renew it until %3$@.", comment: "alert message"), nameStr, badgeDateText(expiredAt), badgeDateText(graceUntil)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%1$@ expired on %2$@.", comment: "alert message"), nameStr, badgeDateText(expiredAt)) + } + alert(NSLocalizedString("Name expired", comment: "alert title"), message) + case let .expired(expiredAt, graceUntil): + let message = if let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ expired on %2$@. Renew it before %3$@.", comment: "alert message"), nameStr, badgeDateText(expiredAt), badgeDateText(graceUntil)) + } else { + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ expired on %2$@.", comment: "alert message"), nameStr, badgeDateText(expiredAt)) + } + alert(NSLocalizedString("Your name has expired", comment: "alert title"), message, action: (NSLocalizedString("Renew", comment: "alert action"), openHowTo)) + case let .available(price) where !own: + alert( + openExistingChat == nil ? NSLocalizedString("Name not registered", comment: "alert title") : NSLocalizedString("Name no longer registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%1$@ is available for registration for %2$@.", comment: "alert message"), nameStr, namePrice(price)), + action: (NSLocalizedString("Register", comment: "alert action"), openHowTo) + ) + case let .available(price): + alert( + NSLocalizedString("Your name has expired", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ is no longer registered. It is available for registration for %2$@.", comment: "alert message"), nameStr, namePrice(price)), + action: (NSLocalizedString("Re-register", comment: "alert action"), openHowTo) + ) + case .reservedForCommunity: + alert( + NSLocalizedString("Name not registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%@ is reserved for community. Please contact SimpleX Chat if you are interested in using it.", comment: "alert message"), nameStr), + action: (NSLocalizedString("Connect to SimpleX team", comment: "alert action"), { + ChatModel.shared.appOpenUrl = simplexTeamURL + }) + ) + case .notRegistered: + alert(NSLocalizedString("Name not registered", comment: "alert title"), NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")) + } +} + // Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, @@ -1325,7 +1368,9 @@ func planAndConnect( dismiss: Bool, cleanup: (() -> Void)? = nil, filterKnownContact: ((Contact) -> Void)? = nil, - filterKnownGroup: ((GroupInfo) -> Void)? = nil + filterKnownGroup: ((GroupInfo) -> Void)? = nil, + showLocalChats: (([ChatInfo]) -> Void)? = nil, + onOpen: (() -> Void)? = nil ) { switch strConnectTarget(shortOrFullLink) { case let .link(_, linkType, _): @@ -1360,6 +1405,10 @@ func planAndConnect( let connectionLink = result.connLink let connectionPlan = result.connectionPlan let planSimplexName = result.planSimplexName + await MainActor.run { + for chat in connectionPlan.localChats { ChatModel.shared.updateChat(chat) } + showLocalChats?(connectionPlan.localChats) + } // the name can also resolve to the other kind; its type picks the verb, its short form the label and target let connectOtherLink = result.otherSimplexName?.shortStr let connectOtherButton: String? = result.otherSimplexName.map { info in @@ -1370,6 +1419,18 @@ func planAndConnect( info.shortStr ) } + let connectOther: (title: String, action: () -> Void)? = if let connectOtherButton, let connectOtherLink { + (connectOtherButton, { planAndConnect(connectOtherLink, theme: theme, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) }) + } else { + nil + } + if case let .some(.lapsed(nameWarning)) = connectionPlan.nameChange, let nameDomain = planSimplexName?.nameDomain { + let openExisting: (() -> Void)? = connectionPlan.planChat.map { chatInfo in { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } + await MainActor.run { + showNameWarningAlert(domain: nameDomain, warning: nameWarning, own: connectionPlan.isOwnLink, openExistingChat: openExisting, cleanup: cleanup) + } + return + } switch connectionPlan { case let .invitationLink(ilp): switch ilp { @@ -1383,7 +1444,8 @@ func planAndConnect( ownerVerification: ownerVerification, theme: theme, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } else { @@ -1395,7 +1457,8 @@ func planAndConnect( connectionPlan: connectionPlan, ownerVerification: ownerVerification, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } @@ -1408,7 +1471,8 @@ func planAndConnect( connectionLink: connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } case let .connecting(contact_): @@ -1418,7 +1482,7 @@ func planAndConnect( if let f = filterKnownContact { f(contact) } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, onOpen: onOpen) } } else { showInvitationLinkConnectingAlert(cleanup: cleanup) @@ -1430,11 +1494,11 @@ func planAndConnect( if let f = filterKnownContact { f(contact) } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, onOpen: onOpen) } } } - case let .contactAddress(cap): + case let .contactAddress(cap, _): switch cap { case let .ok(contactSLinkData_, ownerVerification): if let contactSLinkData = contactSLinkData_ { @@ -1444,12 +1508,13 @@ func planAndConnect( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, ownerVerification: ownerVerification, - verifiedDomain: planSimplexName?.nameDomain, + planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, theme: theme, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } else { @@ -1460,8 +1525,10 @@ func planAndConnect( connectionLink: connectionLink, connectionPlan: connectionPlan, ownerVerification: ownerVerification, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } @@ -1473,8 +1540,10 @@ func planAndConnect( actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } case .connectingConfirmReconnect: @@ -1485,8 +1554,10 @@ func planAndConnect( actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } case let .connectingProhibit(contact): @@ -1495,32 +1566,29 @@ func planAndConnect( if let f = filterKnownContact { f(contact) } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, onOpen: onOpen) } } case let .known(contact): logger.debug("planAndConnect, .contactAddress, .known") await MainActor.run { - if ChatModel.shared.getContactChat(contact.contactId) == nil { - ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) - } if let f = filterKnownContact { f(contact) } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, onOpen: onOpen) } } case let .contactViaAddress(contact): logger.debug("planAndConnect, .contactAddress, .contactViaAddress") await MainActor.run { - showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet( - contact: contact, - dismiss: dismiss, - cleanup: cleanup - ) + if let f = filterKnownContact { + f(contact) + } else { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, onOpen: onOpen) + } } } - case let .groupLink(glp): + case let .groupLink(glp, _): switch glp { case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification): if let groupSLinkData = groupSLinkData_ { @@ -1531,12 +1599,13 @@ func planAndConnect( groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, ownerVerification: ownerVerification, - verifiedDomain: planSimplexName?.nameDomain, + planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, theme: theme, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } else { @@ -1547,8 +1616,10 @@ func planAndConnect( connectionLink: connectionLink, connectionPlan: connectionPlan, ownerVerification: ownerVerification, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } @@ -1562,8 +1633,11 @@ func planAndConnect( groupInfo: groupInfo, connectionLink: connectionLink, connectionPlan: connectionPlan, + planSimplexName: planSimplexName, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } case .connectingConfirmReconnect: @@ -1574,8 +1648,10 @@ func planAndConnect( actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, + connectOther: connectOther, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } case let .connectingProhibit(groupInfo_): @@ -1586,13 +1662,10 @@ func planAndConnect( case let .known(groupInfo): logger.debug("planAndConnect, .groupLink, .known") await MainActor.run { - if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil { - ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) - } if let f = filterKnownGroup { f(groupInfo) } else { - showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, onOpen: onOpen) } } case let .noRelays(groupSLinkData_): @@ -1611,8 +1684,9 @@ func planAndConnect( theme: theme, subtitle: NSLocalizedString("Channel has no active relays. Please try to join later.", comment: "alert subtitle"), cancelTitle: NSLocalizedString("OK", comment: "alert button"), - confirmTitle: nil, - onCancel: { cleanup?() } + confirmTitle: connectOther?.title, + onCancel: { cleanup?() }, + onConfirm: connectOther?.action ) } else { showAlert( @@ -1638,8 +1712,9 @@ func planAndConnect( theme: theme, subtitle: NSLocalizedString("This group requires a newer version of the app. Please update the app to join.", comment: "alert subtitle"), cancelTitle: NSLocalizedString("OK", comment: "alert button"), - confirmTitle: nil, - onCancel: { cleanup?() } + confirmTitle: connectOther?.title, + onCancel: { cleanup?() }, + onConfirm: connectOther?.action ) } else { showAlert( @@ -1657,7 +1732,8 @@ func planAndConnect( connectionLink: connectionLink, connectionPlan: nil, dismiss: dismiss, - cleanup: cleanup + cleanup: cleanup, + onOpen: onOpen ) } } else if let cleanup { @@ -1667,32 +1743,19 @@ func planAndConnect( } } -private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incognito: Bool, cleanup: (() -> Void)? = nil) { - Task { - if dismiss { - DispatchQueue.main.async { - dismissAllSheets(animated: true) - } - } - let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) - if ok { - AlertManager.shared.showAlert(connReqSentAlert(.contact)) - } - cleanup?() - } -} - private func connectViaLink( _ connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool, - cleanup: (() -> Void)? + cleanup: (() -> Void)?, + onOpen: (() -> Void)? ) { Task { if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) { await MainActor.run { ChatModel.shared.updateContactConnection(pcc) + onOpen?() } let crt: ConnReqType crt = if let plan = connectionPlan { @@ -1720,19 +1783,20 @@ private func connectViaLink( } } -func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) { +func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?, onOpen: (() -> Void)? = nil) { if let c = ChatModel.shared.getContactChat(contact.contactId) { - openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup) + openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } -func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) { +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?, onOpen: (() -> Void)? = nil) { if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) { - openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup) + openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup, onOpen: onOpen) } } -func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) { +func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?, onOpen: (() -> Void)? = nil) { + onOpen?() if dismiss { dismissAllSheets(animated: true) { ItemsModel.shared.loadOpenChat(chatId) { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a8c5de3672..1627bf3272 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -176,7 +176,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { localBadge: LocalBadge? = nil, localAlias: String, contactDomain: SimplexDomainClaim? = nil, - contactDomainVerified: Bool? = nil + contactDomainVerified: DomainVerification? = nil ) { self.profileId = profileId self.displayName = displayName @@ -205,7 +205,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable { public var localBadge: LocalBadge? public var localAlias: String public var contactDomain: SimplexDomainClaim? - public var contactDomainVerified: Bool? + public var contactDomainVerified: DomainVerification? public var profileDescription: String? { description } @@ -547,7 +547,7 @@ public struct BadgeAlert: Codable, Hashable { public var dateText: String { badgeDateText(date) } } -private func badgeDateText(_ date: Date) -> String { +public func badgeDateText(_ date: Date) -> String { DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .none) } @@ -2888,7 +2888,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var chatTags: [Int64] public var chatItemTTL: Int64? public var localAlias: String - public var groupDomainVerified: Bool? + public var groupDomainVerified: DomainVerification? public var isOwner: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -2995,9 +2995,28 @@ public struct SimplexDomainClaim: Codable, Hashable { } } +public enum DomainVerification: String, Codable, Hashable { + case failed + case verified + case moved +} + public enum SimplexDomainError: Decodable, Hashable { case noValidLink case unknownDomain + case nameWarning(nameWarning: NameWarning) +} + +public enum NameWarning: Decodable, Hashable { + case expired(expiredAt: Date, graceUntil: Date?) + case available(price: NamePrice) + case reservedForCommunity + case notRegistered +} + +public struct NamePrice: Decodable, Hashable { + public var amount: Int64 + public var years: Int } public struct RelayCapabilities: Codable, Hashable { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 1afea4541b..ead9ea1443 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -507,7 +507,7 @@ object ChatModel { suspend fun updateGroup(rhId: Long?, groupInfo: GroupInfo) = updateChat(rhId, ChatInfo.Group(groupInfo, groupChatScope = null)) - private suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { + suspend fun updateChat(rhId: Long?, cInfo: ChatInfo, addMissing: Boolean = true) { if (hasChat(rhId, cInfo.id)) { updateChatInfo(rhId, cInfo) } else if (addMissing) { @@ -2140,7 +2140,7 @@ data class LocalProfile( val peerType: ChatPeerType? = null, val localBadge: LocalBadge? = null, val contactDomain: SimplexDomainClaim? = null, - val contactDomainVerified: Boolean? = null + val contactDomainVerified: DomainVerification? = null ): NamedChat { override val profileDescription: String? get() = description @@ -2450,7 +2450,7 @@ enum class BadgeAlertKind { @SerialName("issueFailed") IssueFailed } -private fun badgeDateText(date: Instant): String { +fun badgeDateText(date: Instant): String { val ts = date.toLocalDateTime(TimeZone.currentSystemDefault()) return ts.toJavaLocalDateTime().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)) } @@ -2506,7 +2506,7 @@ data class GroupInfo ( val chatTags: List, val chatItemTTL: Long?, override val localAlias: String, - val groupDomainVerified: Boolean? = null, + val groupDomainVerified: DomainVerification? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -2638,6 +2638,13 @@ data class SimplexDomainClaim( val shortName: String get() = domain.removeSuffix(".simplex") } +@Serializable +enum class DomainVerification { + @SerialName("failed") Failed, + @SerialName("verified") Verified, + @SerialName("moved") Moved, +} + @Serializable data class PublicGroupAccess( val groupWebPage: String? = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index faf27d3ceb..ac58b226f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -34,6 +34,7 @@ import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.database.deleteDatabaseBackups import chat.simplex.common.views.migration.MigrationFileLinkData +import chat.simplex.common.views.newchat.showNameWarningAlert import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink @@ -1689,6 +1690,8 @@ object ChatController { generalGetString(MR.strings.simplex_name_no_valid_link), generalGetString(MR.strings.simplex_name_no_valid_link_desc).format(domain) ) + } else if (r.err.errorType.simplexDomainError is SimplexDomainError.NameWarning) { + showNameWarningAlert(r.rhId, r.err.errorType.simplexDomain, r.err.errorType.simplexDomainError.nameWarning, own = false, openExistingChat = null, cleanup = null) } else { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.simplex_name_unconfirmed), @@ -2987,6 +2990,25 @@ object ChatController { } } } + is CR.NameMoved -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + fun chatInfo(id: String) = chatModel.chatsContext.chats.value.firstOrNull { it.remoteHostId == rhId && it.id == id }?.chatInfo + r.contactIds.forEach { contactId -> + val cInfo = chatInfo("@$contactId") + if (cInfo is ChatInfo.Direct) { + chatModel.chatsContext.updateChatInfo(rhId, ChatInfo.Direct(cInfo.contact.copy(profile = cInfo.contact.profile.copy(contactDomainVerified = DomainVerification.Moved)))) + } + } + r.groupIds.forEach { groupId -> + val cInfo = chatInfo("#$groupId") + if (cInfo is ChatInfo.Group) { + chatModel.chatsContext.updateChatInfo(rhId, ChatInfo.Group(cInfo.groupInfo.copy(groupDomainVerified = DomainVerification.Moved), groupChatScope = null)) + } + } + } + } + } is CR.GroupMemberUpdated -> { if (active(r.user)) { withContext(Dispatchers.Main) { @@ -6796,6 +6818,7 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef, val contactRequest: UserContactRequest, val contact_: Contact?): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("nameMoved") class NameMoved(val user: UserRef, val contactIds: List, val groupIds: List): CR() @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() @Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List): CR() @Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR() @@ -6998,6 +7021,7 @@ sealed class CR { is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" + is NameMoved -> "nameMoved" is GroupMemberUpdated -> "groupMemberUpdated" is SubscriptionStatusEvt -> "subscriptionStatus" is ChatInfoUpdated -> "chatInfoUpdated" @@ -7190,6 +7214,7 @@ sealed class CR { is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) is ContactRequestRejected -> withUser(user, "contactRequest: ${json.encodeToString(contactRequest)}\ncontact_: ${json.encodeToString(contact_)}") is ContactUpdated -> withUser(user, json.encodeToString(toContact)) + is NameMoved -> withUser(user, "contactIds: $contactIds\ngroupIds: $groupIds") is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections" is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo)) @@ -7372,6 +7397,7 @@ sealed class OwnerVerification { sealed class SimplexDomainError { @Serializable @SerialName("noValidLink") object NoValidLink : SimplexDomainError() @Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError() + @Serializable @SerialName("nameWarning") class NameWarning(val nameWarning: chat.simplex.common.model.NameWarning) : SimplexDomainError() } @Serializable @@ -7495,12 +7521,52 @@ enum class PlanResolveMode { @Serializable sealed class ConnectionPlan { + open val nameChange: NameChange? get() = null + val localChats: List get() = listOfNotNull(planChat, knownChat) + val planChat: ChatInfo? get() = when (this) { + is ContactAddress -> when (val p = contactAddressPlan) { + is ContactAddressPlan.ConnectingProhibit -> ChatInfo.Direct(p.contact) + is ContactAddressPlan.Known -> ChatInfo.Direct(p.contact) + is ContactAddressPlan.ContactViaAddress -> ChatInfo.Direct(p.contact) + else -> null + } + is GroupLink -> when (val p = groupLinkPlan) { + is GroupLinkPlan.OwnLink -> p.groupInfo + is GroupLinkPlan.Known -> p.groupInfo + is GroupLinkPlan.ConnectingProhibit -> p.groupInfo_ + else -> null + }?.let { ChatInfo.Group(it, groupChatScope = null) } + else -> null + } + val knownChat: ChatInfo? get() = (nameChange as? NameChange.Moved)?.knownChat + val isOwnLink: Boolean get() = when (this) { + is ContactAddress -> contactAddressPlan is ContactAddressPlan.OwnLink + is GroupLink -> groupLinkPlan is GroupLinkPlan.OwnLink + else -> false + } @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() - @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() - @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan, override val nameChange: NameChange? = null): ConnectionPlan() + @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan, override val nameChange: NameChange? = null): ConnectionPlan() @Serializable @SerialName("error") class Error(val chatError: ChatError): ConnectionPlan() } +@Serializable +sealed class NameChange { + @Serializable @SerialName("lapsed") class Lapsed(val nameWarning: NameWarning): NameChange() + @Serializable @SerialName("moved") class Moved(val knownChat: ChatInfo): NameChange() +} + +@Serializable +sealed class NameWarning { + @Serializable @SerialName("expired") class Expired(val expiredAt: Instant, val graceUntil: Instant? = null): NameWarning() + @Serializable @SerialName("available") class Available(val price: NamePrice): NameWarning() + @Serializable @SerialName("reservedForCommunity") object ReservedForCommunity: NameWarning() + @Serializable @SerialName("notRegistered") object NotRegistered: NameWarning() +} + +@Serializable +data class NamePrice(val amount: Long, val years: Int) + @Serializable sealed class InvitationLinkPlan { @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): InvitationLinkPlan() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt index 7915aedca5..545fd66497 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -20,16 +20,16 @@ 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. +// Renders a contact's / channel's SimpleX name with its verification indicator. +// `verified`: null = not attempted, Failed, Verified, or Moved to another chat. // `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( simplexName: String, - verified: Boolean?, + verified: DomainVerification?, verifiable: Boolean = true, - verify: suspend () -> Pair? + verify: suspend () -> Pair? ) { val scope = rememberCoroutineScope() val inFlight = remember { mutableStateOf(false) } @@ -66,7 +66,7 @@ fun SimplexNameView( val clipboard = LocalClipboardManager.current val nameStyle = MaterialTheme.typography.body2.copy( - color = if (verified == true) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + color = if (verified == DomainVerification.Verified) MaterialTheme.colors.primary else MaterialTheme.colors.secondary ) Row( verticalAlignment = Alignment.CenterVertically, @@ -78,14 +78,14 @@ fun SimplexNameView( Text(simplexName, style = nameStyle) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colors.secondary) } - verified == true -> + verified == DomainVerification.Verified -> SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_check_filled, MaterialTheme.colors.primary) { clipboard.setText(AnnotatedString(simplexName)) showToast(generalGetString(MR.strings.copied)) } !verifiable -> Text(simplexName, style = nameStyle) - verified == false -> - SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, Color.Red) { runVerify(manual = true) } + verified == DomainVerification.Failed || verified == DomainVerification.Moved -> + SimplexNameWithIcon(simplexName, nameStyle, MR.images.ic_close, if (verified == DomainVerification.Failed) Color.Red else MaterialTheme.colors.secondary) { runVerify(manual = true) } else -> { Text(simplexName, style = nameStyle) Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 17101c35d1..1495c4d120 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -851,16 +851,16 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState if (candidate != null) { // resolve the name locally on each keystroke, debounced; collectLatest cancels the in-flight // search when the next keystroke arrives. A bare name can be a contact or a channel, so search - // both and filter every known chat found; drop the row only when both types are already known. + // both and filter every known chat found. delay(NAME_SEARCH_DEBOUNCE_MS) val rhId = chatModel.remoteHostId() val inProgress = mutableStateOf(false) // background search: no spinner, no error alerts val targets = if (candidate.startsWith("@") || candidate.startsWith("#")) listOf(candidate) else listOf("@$candidate", "#$candidate") - val ids = targets.mapNotNull { name -> - knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress)) + val localChats = targets.flatMap { name -> + chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress)?.connectionPlan?.localChats ?: emptyList() } - searchChatFilteredBySimplexLink.value = ids.toSet() - if (ids.size == targets.size) connectNameCandidate.value = null + localChats.forEach { chatModel.chatsContext.updateChat(rhId, it) } + searchChatFilteredBySimplexLink.value = localChats.map { it.id }.toSet() } else if (!searchShowingSimplexLink.value || it.isEmpty()) { if (it.isNotEmpty()) { focusRequester.requestFocus() @@ -985,6 +985,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val searchShowingSimplexLink = remember { mutableStateOf(false) } val searchChatFilteredBySimplexLink = remember { mutableStateOf>(emptySet()) } val connectNameCandidate = remember { mutableStateOf(null) } + val showLocalChats: (List) -> Unit = { localChats -> searchChatFilteredBySimplexLink.value += localChats.map { it.id } } val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value) val topPaddingToContent = topPaddingToContent(false) val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent @@ -1018,10 +1019,10 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) { Divider() // bottom toolbar: search bar below, so on desktop the connect row goes below the tags - TagsOrConnectByName(searchText, connectNameCandidate) { candidate -> + TagsOrConnectByName(searchText, connectNameCandidate, showLocalChats) { candidate -> TagsView(searchText) Divider() - ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null) + ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats) } ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) @@ -1029,8 +1030,8 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } else { ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate) // top toolbar: search bar above, so on desktop the connect row goes above the tags - TagsOrConnectByName(searchText, connectNameCandidate) { candidate -> - ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null) + TagsOrConnectByName(searchText, connectNameCandidate, showLocalChats) { candidate -> + ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats) Divider() TagsView(searchText) } @@ -1162,30 +1163,6 @@ internal fun nameSearchCandidate(str: String): String? { } } -// The chat id a local (PRMNever) search resolved to — a contact, a business, or a channel — or null on a miss. -// The core returns the correct type for @ vs # (getContactToConnect / type-filtered getGroupToConnect), so no -// client-side type check is needed. -internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): String? = when (val plan = result?.connectionPlan) { - is ConnectionPlan.ContactAddress -> (plan.contactAddressPlan as? ContactAddressPlan.Known)?.contact?.let { contact -> - // a name-resolved chat may be prepared in the store but not yet listed, so add it (as the tap path does) - if (chatModel.getContactChat(contact.contactId) == null) { - chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList())) - } - contact.id - } - is ConnectionPlan.GroupLink -> (when (val g = plan.groupLinkPlan) { - is GroupLinkPlan.Known -> g.groupInfo - is GroupLinkPlan.OwnLink -> g.groupInfo - else -> null - })?.let { gInfo -> - if (chatModel.getGroupChat(gInfo.groupId) == null) { - chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(gInfo, groupChatScope = null), chatItems = emptyList())) - } - gInfo.id - } - else -> null -} - // The list tags and the connect-by-name row share one slot. When there is no name, the tags show; on // mobile the row replaces the tags while shown. On desktop both show, arranged by the caller (which // knows whether the search bar is above or below), passed as desktopView. @@ -1193,18 +1170,19 @@ internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): St private fun TagsOrConnectByName( searchText: MutableState, connectNameCandidate: MutableState, + showLocalChats: (List) -> Unit, desktopView: @Composable (candidate: String) -> Unit, ) { val candidate = connectNameCandidate.value when { candidate == null -> TagsView(searchText) - !appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null) + !appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null, showLocalChats = showLocalChats) else -> desktopView(candidate) } } @Composable -internal fun ConnectByNameRow(name: String, searchText: MutableState, connectNameCandidate: MutableState, close: (() -> Unit)?) { +internal fun ConnectByNameRow(name: String, searchText: MutableState, connectNameCandidate: MutableState, close: (() -> Unit)?, showLocalChats: ((List) -> Unit)? = null) { val view = LocalMultiplatformView() Row( Modifier @@ -1215,11 +1193,12 @@ internal fun ConnectByNameRow(name: String, searchText: MutableState Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, + showLocalChats: ((List) -> Unit)? = null, ): CompletableDeferred { when (val target = strConnectTarget(shortOrFullLink.trim())) { is ConnectTarget.Link -> { @@ -51,7 +56,82 @@ suspend fun planAndConnect( inProgress.value = false cleanup?.invoke() } - return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) + return planAndConnectTask(rhId, shortOrFullLink, linkOwnerSig, close, cleanup, filterKnownContact, filterKnownGroup, showLocalChats, inProgress) +} + +private fun namePrice(price: NamePrice): String { + val dollars = "$" + (price.amount / 100).toString() + (if (price.amount % 100 == 0L) "" else ".%02d".format(price.amount % 100)) + return String.format(generalGetString(MR.strings.simplex_name_price_for_years), dollars, price.years) +} + +private fun openNameHowTo(uriHandler: UriHandler) = openBrowserAlert("https://simplex.domains/#testing", uriHandler) + +fun showNameWarningAlert( + rhId: Long?, + domain: SimplexDomain, + warning: NameWarning, + own: Boolean, + openExistingChat: (() -> Unit)?, + cleanup: (() -> Unit)? +) { + val nameStr = domain.fullDomainName + fun dismiss() { + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + } + fun alert(title: String, text: String, action: Pair Unit>? = null) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = title, + text = text, + buttons = { + val uriHandler = LocalUriHandler.current + Column { + if (action != null) { + SectionItemView({ dismiss(); action.second(uriHandler) }) { + Text(action.first, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + if (openExistingChat != null) { + SectionItemView({ dismiss(); openExistingChat() }) { + Text(generalGetString(MR.strings.connect_plan_open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView(::dismiss) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } + when (warning) { + is NameWarning.Expired -> if (!own) alert( + generalGetString(MR.strings.simplex_name_expired), + if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_expired_desc), nameStr, badgeDateText(warning.expiredAt), badgeDateText(warning.graceUntil)) + else String.format(generalGetString(MR.strings.simplex_name_expired_no_date_desc), nameStr, badgeDateText(warning.expiredAt)) + ) else alert( + generalGetString(MR.strings.simplex_name_own_expired), + if (warning.graceUntil != null) String.format(generalGetString(MR.strings.simplex_name_own_expired_desc), nameStr, badgeDateText(warning.expiredAt), badgeDateText(warning.graceUntil)) + else String.format(generalGetString(MR.strings.simplex_name_own_expired_no_date_desc), nameStr, badgeDateText(warning.expiredAt)), + generalGetString(MR.strings.simplex_name_renew) to ::openNameHowTo + ) + is NameWarning.Available -> if (!own) alert( + generalGetString(if (openExistingChat == null) MR.strings.simplex_name_not_registered else MR.strings.simplex_name_no_longer_registered), + String.format(generalGetString(MR.strings.simplex_name_available_desc), nameStr, namePrice(warning.price)), + generalGetString(MR.strings.simplex_name_register) to ::openNameHowTo + ) else alert( + generalGetString(MR.strings.simplex_name_own_expired), + String.format(generalGetString(MR.strings.simplex_name_own_available_desc), nameStr, namePrice(warning.price)), + generalGetString(MR.strings.simplex_name_re_register) to ::openNameHowTo + ) + NameWarning.ReservedForCommunity -> alert( + generalGetString(MR.strings.simplex_name_not_registered), + String.format(generalGetString(MR.strings.simplex_name_reserved_community_desc), nameStr), + generalGetString(MR.strings.simplex_name_connect_simplex_team) to { uh: UriHandler -> uh.openVerifiedSimplexUri(simplexTeamUri) } + ) + NameWarning.NotRegistered -> alert(generalGetString(MR.strings.simplex_name_not_registered), generalGetString(MR.strings.simplex_name_not_found_desc)) + } } private suspend fun planAndConnectTask( @@ -62,6 +142,7 @@ private suspend fun planAndConnectTask( cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, + showLocalChats: ((List) -> Unit)? = null, inProgress: MutableState ): CompletableDeferred { val completable = CompletableDeferred() @@ -79,6 +160,10 @@ private suspend fun planAndConnectTask( if (!inProgress.value) { return completable } if (result != null) { val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result + withContext(Dispatchers.Main) { + connectionPlan.localChats.forEach { chatModel.chatsContext.updateChat(rhId, it) } + showLocalChats?.invoke(connectionPlan.localChats) + } val target = strConnectTarget(shortOrFullLink.trim()) val linkText = if (target is ConnectTarget.Link) "

${target.linkText}" else "" // the name can also resolve to the other kind; its type picks the verb, its short form the label and target @@ -87,6 +172,13 @@ private suspend fun planAndConnectTask( val label = if (it.nameType == SimplexNameType.publicGroup) MR.strings.connect_plan_join_name else MR.strings.connect_plan_connect_to_name generalGetString(label).format(it.shortStr) } + val nameWarning = (connectionPlan.nameChange as? NameChange.Lapsed)?.nameWarning + val nameDomain = planSimplexName?.nameDomain + if (nameWarning != null && nameDomain != null) { + val openExisting: (() -> Unit)? = connectionPlan.planChat?.let { chatInfo -> { withBGApi { close(); openChat(secondaryChatsCtx = null, rhId, chatInfo) } } } + showNameWarningAlert(rhId, nameDomain, nameWarning, connectionPlan.isOwnLink, openExisting, cleanup) + return completable + } when (connectionPlan) { is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { is InvitationLinkPlan.Ok -> @@ -216,12 +308,6 @@ 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 { @@ -233,10 +319,7 @@ private suspend fun planAndConnectTask( Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress") val contact = connectionPlan.contactAddressPlan.contact // the contact is already prepared in the store, so open the existing chat instead of sending a new - // connection request; surface it in the chat list first if it is not there yet (as for Known above) - if (chatModel.getContactChat(contact.contactId) == null) { - chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList())) - } + // connection request if (filterKnownContact != null) { filterKnownContact(contact) } else { @@ -322,11 +405,6 @@ 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 { @@ -350,6 +428,8 @@ private suspend fun planAndConnectTask( }, subtitle = generalGetString(MR.strings.channel_no_active_relays_try_later), confirmText = null, + connectOtherButton = connectOtherButton, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } }, dismissText = generalGetString(MR.strings.ok), onDismiss = { cleanup() } ) @@ -377,6 +457,8 @@ private suspend fun planAndConnectTask( }, subtitle = generalGetString(MR.strings.group_link_requires_newer_version), confirmText = null, + connectOtherButton = connectOtherButton, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } }, dismissText = generalGetString(MR.strings.ok), onDismiss = { cleanup() } ) diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 326be2b05d..9a188cbd14 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -205,6 +205,22 @@ Resolver error: %1$s No valid link The SimpleX name %1$s is registered, but it has no valid link. + Name expired + %1$s expired on %2$s. Its owner can renew it until %3$s. + %1$s expired on %2$s. + Your name has expired + Your name %1$s expired on %2$s. Renew it before %3$s. + Your name %1$s expired on %2$s. + Your name %1$s is no longer registered. It is available for registration for %2$s. + Name not registered + Name no longer registered + %1$s is available for registration for %2$s. + %1$s for %2$d years + %1$s is reserved for community. Please contact SimpleX Chat if you are interested in using it. + Register + Re-register + Renew + Connect to SimpleX team Unconfirmed name The SimpleX name %1$s is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. Channel temporarily unavailable diff --git a/apps/multiplatform/product/flows/connection.md b/apps/multiplatform/product/flows/connection.md index 1b1123b535..b5a4b536ee 100644 --- a/apps/multiplatform/product/flows/connection.md +++ b/apps/multiplatform/product/flows/connection.md @@ -58,13 +58,23 @@ suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState ```kotlin sealed class ConnectionPlan { + val localChats: List + val planChat: ChatInfo? + val knownChat: ChatInfo? class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() - class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() - class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + class ContactAddress(val contactAddressPlan: ContactAddressPlan, val nameChange: NameChange? = null): ConnectionPlan() + class GroupLink(val groupLinkPlan: GroupLinkPlan, val nameChange: NameChange? = null): ConnectionPlan() class Error(val chatError: ChatError): ConnectionPlan() } + +sealed class NameChange { + class Lapsed(val nameWarning: NameWarning): NameChange() + class Moved(val knownChat: ChatInfo): NameChange() +} ``` +`planChat` is the plan's contact or group, `knownChat` is the chat of `Moved`, and `localChats` lists both. + 4. For `InvitationLinkPlan`: - `Ok`: Fresh invitation, safe to connect. - `OwnLink`: User's own link, alert shown. @@ -98,17 +108,20 @@ suspend fun planAndConnect( cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, + showLocalChats: ((List) -> Unit)? = null, ): CompletableDeferred ``` 1. A progress indicator is shown. 2. `apiConnectPlan` is called to analyze the link. -3. Based on the plan type, the appropriate UI is shown: +3. `localChats` are added to or updated in the chat list, and passed to `showLocalChats`. +4. Based on the plan type, the appropriate UI is shown: + - For a name warning (`NameChange.Lapsed`): the name warning alert, with Open chat for `planChat`. With nothing local, the warning is the error `SimplexDomainError.NameWarning`, and the same alert is shown by the connect error handler. - For `Ok` plans: proceed to `apiConnect`. - For `Known`: navigate to the existing contact/group. - For `OwnLink`: show alert. - For `Connecting`: show reconnect confirmation or prohibit. -4. Returns a `CompletableDeferred` indicating success. +5. Returns a `CompletableDeferred` indicating success. ### 2.3 Execute Connection diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 76c3f43400..211b23f401 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -546,7 +546,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " is updated" <> byMember <> "." verifyAndSendToApprove g' gr' n' sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups Nothing) >>= \case - Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) -> + Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g'}) _)) -> case dbOwnerMemberId gr of Just ownerGMId -> withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case @@ -758,7 +758,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o sendToApprove g@GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image', publicGroup = pg_}, groupSummary} GroupReg {dbContactId, promoted} gaId = do ct_ <- getContact' cc user dbContactId let gt = maybe "group" groupTypeStr' pg_ - nameStr_ = (\d -> simplexNameStr d <> (if groupDomainVerified g == Just True then "" else " (NOT verified - will not be shown)")) <$> groupSimplexDomain g + nameStr_ = (\d -> simplexNameStr d <> (if groupDomainVerified g == Just DVVerified then "" else " (NOT verified - will not be shown)")) <$> groupSimplexDomain g membersStr = "_" <> membersCountStr p groupSummary <> "_\n" text = either (\_ -> "The " <> gt <> " ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the " <> gt <> " ID " <> tshow groupId <> ": ") ct_ @@ -778,7 +778,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o when (groupRegStatus == GRSActive || pendingApproval groupRegStatus) $ do let link = ACL SCMContact $ CLShort groupLink sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups Nothing) >>= \case - Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated, linkOwners = ListDef owners}))) -> + Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated, linkOwners = ListDef owners}) _)) -> checkValidOwner dbOwnerMemberId owners $ do -- re-verify every cycle: a name that stopped resolving to the link must lose verified status g'' <- verifyGroupDomain_ g' @@ -939,7 +939,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o handleGroupLinkPlan :: Contact -> CreatedLinkContact -> MemberId -> LinkOwnerSig -> Text -> ConnectionPlan -> IO () handleGroupLinkPlan ct ccLink mId ownerSig gt = \case - CPGroupLink glp -> case glp of + CPGroupLink glp _ -> case glp of GLPOk {groupSLinkData_, ownerVerification} -> case (groupSLinkData_, ownerVerification) of (Just groupSLinkData, Just OVVerified) -> joinAndRegisterPublicGroup ct ccLink mId gt groupSLinkData (_, Just (OVFailed reason)) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it." @@ -1253,7 +1253,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o getRegisteredGroupByLink :: AConnectionLink -> IO (Maybe (GroupInfo, GroupReg, CreatedLinkContact)) getRegisteredGroupByLink uri = sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget uri)) PRMNever Nothing) >>= \case - Right (CRConnectionPlan _ (ACCL SCMContact ccLink) _ _ (CPGroupLink glp)) -> case glp of + Right (CRConnectionPlan _ (ACCL SCMContact ccLink) _ _ (CPGroupLink glp _)) -> case glp of GLPOwnLink g -> groupReg g ccLink GLPKnown {groupInfo = g} -> groupReg g ccLink GLPConnectingProhibit (Just g) -> groupReg g ccLink diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 02a2d2f5be..e4b53815db 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -206,7 +206,7 @@ grDirectoryStatus = \case verifiedGroupDomain :: GroupInfo -> Maybe SimplexDomain verifiedGroupDomain GroupInfo {groupProfile = GroupProfile {publicGroup}, groupDomainVerified} - | groupDomainVerified == Just True = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim) + | groupDomainVerified == Just DVVerified = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim) | otherwise = Nothing $(JQ.deriveJSON (enumJSON $ dropPrefix "PC") ''ProfileCondition) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..c6c8d34b77 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -81,6 +81,7 @@ This file is generated automatically. - [CryptoFile](#cryptofile) - [CryptoFileArgs](#cryptofileargs) - [CtrlAppInfo](#ctrlappinfo) +- [DomainVerification](#domainverification) - [DroppedMsg](#droppedmsg) - [E2EInfo](#e2einfo) - [ErrorType](#errortype) @@ -144,7 +145,10 @@ This file is generated automatically. - [MsgReceiptStatus](#msgreceiptstatus) - [MsgSigStatus](#msgsigstatus) - [MsgVerified](#msgverified) +- [NameChange](#namechange) - [NameErrorType](#nameerrortype) +- [NamePrice](#nameprice) +- [NameWarning](#namewarning) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) @@ -1870,10 +1874,12 @@ InvitationLink: ContactAddress: - type: "contactAddress" - contactAddressPlan: [ContactAddressPlan](#contactaddressplan) +- nameChange: [NameChange](#namechange)? GroupLink: - type: "groupLink" - groupLinkPlan: [GroupLinkPlan](#grouplinkplan) +- nameChange: [NameChange](#namechange)? Error: - type: "error" @@ -2055,6 +2061,16 @@ Remote controller application info. - compression: bool +--- + +## DomainVerification + +**Enum type**: +- "failed" +- "verified" +- "moved" + + --- ## DroppedMsg @@ -2494,7 +2510,7 @@ MemberSupport: - rosterVersion: int64? - membersRequireAttention: int - viaGroupLinkUri: string? -- groupDomainVerified: bool? +- groupDomainVerified: [DomainVerification](#domainverification)? --- @@ -2897,7 +2913,7 @@ Unknown: - localBadge: [LocalBadge](#localbadge)? - localAlias: string - contactDomain: [SimplexDomainClaim](#simplexdomainclaim)? -- contactDomainVerified: bool? +- contactDomainVerified: [DomainVerification](#domainverification)? --- @@ -3087,6 +3103,21 @@ SigMissing: - type: "sigMissing" +--- + +## NameChange + +**Discriminated union type**: + +Lapsed: +- type: "lapsed" +- nameWarning: [NameWarning](#namewarning) + +Moved: +- type: "moved" +- knownChat: [ChatInfo](#chatinfo) + + --- ## NameErrorType @@ -3104,6 +3135,37 @@ RESOLVER: - resolverErr: string +--- + +## NamePrice + +**Record type**: +- amount: int64 +- years: int + + +--- + +## NameWarning + +**Discriminated union type**: + +Expired: +- type: "expired" +- expiredAt: UTCTime +- graceUntil: UTCTime? + +Available: +- type: "available" +- price: [NamePrice](#nameprice) + +ReservedForCommunity: +- type: "reservedForCommunity" + +NotRegistered: +- type: "notRegistered" + + --- ## NetworkError @@ -3820,6 +3882,10 @@ NoValidLink: UnknownDomain: - type: "unknownDomain" +NameWarning: +- type: "nameWarning" +- nameWarning: [NameWarning](#namewarning) + --- diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 48f307f6e4..9b1b14c908 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -206,6 +206,7 @@ undocumentedEvents = "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", + "CEvtNameMoved", "CEvtNewRemoteHost", "CEvtNoMemberContactCreating", "CEvtNtfMessage", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 3adcaef783..174bba1cba 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -272,6 +272,7 @@ chatTypesDocsData = (sti @ContactUserPreferences, STRecord, "", [], "", ""), (sti @CryptoFile, STRecord, "", [], "", ""), (sti @CryptoFileArgs, STRecord, "", [], "", ""), + (sti @DomainVerification, STEnum, "DV", [], "", ""), (sti @DroppedMsg, STRecord, "", [], "", ""), (sti @E2EInfo, STRecord, "", [], "", ""), (sti @ErrorType, STUnion, "", [], "", ""), @@ -334,7 +335,10 @@ chatTypesDocsData = (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), (sti @MsgVerified, STUnion, "MV", [], "", ""), + (sti @NameChange, STUnion, "NC", [], "", ""), (sti @NameErrorType, STUnion, "", [], "", ""), + (sti @NamePrice, STRecord, "", [], "", ""), + (sti @NameWarning, STUnion, "NW", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), @@ -505,6 +509,7 @@ deriving instance Generic ContactStatus deriving instance Generic ContactUserPreferences deriving instance Generic CryptoFile deriving instance Generic CryptoFileArgs +deriving instance Generic DomainVerification deriving instance Generic DroppedMsg deriving instance Generic E2EInfo deriving instance Generic ErrorType @@ -574,7 +579,10 @@ deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus deriving instance Generic MsgSigStatus deriving instance Generic MsgVerified +deriving instance Generic NameChange deriving instance Generic NameErrorType +deriving instance Generic NamePrice +deriving instance Generic NameWarning deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 59bfe4e52e..e2c5f78a7a 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -169,12 +169,14 @@ toTypeInfo tr = "AgentUserId" -> ST TInt64 [] "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] + "USDCents" -> ST TInt64 [] "Version" -> ST TInt [] "VersionRoster" -> ST TInt64 [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] "ACreatedConnLink" -> ST "CreatedConnLink" [] + "AChatInfo" -> ST "ChatInfo" [] "UserChatRelay'" -> ST "UserChatRelay" [] "CChatItem" -> ST "ChatItem" [] "FormatColor" -> ST "Color" [] diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..49c81128f6 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2137,11 +2137,13 @@ export namespace ConnectionPlan { export interface ContactAddress extends Interface { type: "contactAddress" contactAddressPlan: ContactAddressPlan + nameChange?: NameChange } export interface GroupLink extends Interface { type: "groupLink" groupLinkPlan: GroupLinkPlan + nameChange?: NameChange } export interface Error extends Interface { @@ -2306,6 +2308,12 @@ export interface CtrlAppInfo { compression: boolean } +export enum DomainVerification { + Failed = "failed", + Verified = "verified", + Moved = "moved", +} + export interface DroppedMsg { brokerTs: string // ISO-8601 timestamp attempts: number // int @@ -2796,7 +2804,7 @@ export interface GroupInfo { rosterVersion?: number // int64 membersRequireAttention: number // int viaGroupLinkUri?: string - groupDomainVerified?: boolean + groupDomainVerified?: DomainVerification } export interface GroupLink { @@ -3151,7 +3159,7 @@ export interface LocalProfile { localBadge?: LocalBadge localAlias: string contactDomain?: SimplexDomainClaim - contactDomainVerified?: boolean + contactDomainVerified?: DomainVerification } export enum MemberCriteria { @@ -3364,6 +3372,26 @@ export namespace MsgVerified { } } +export type NameChange = NameChange.Lapsed | NameChange.Moved + +export namespace NameChange { + export type Tag = "lapsed" | "moved" + + interface Interface { + type: Tag + } + + export interface Lapsed extends Interface { + type: "lapsed" + nameWarning: NameWarning + } + + export interface Moved extends Interface { + type: "moved" + knownChat: ChatInfo + } +} + export type NameErrorType = NameErrorType.NO_RESOLVER | NameErrorType.NOT_FOUND | NameErrorType.RESOLVER export namespace NameErrorType { @@ -3387,6 +3415,44 @@ export namespace NameErrorType { } } +export interface NamePrice { + amount: number // int64 + years: number // int +} + +export type NameWarning = + | NameWarning.Expired + | NameWarning.Available + | NameWarning.ReservedForCommunity + | NameWarning.NotRegistered + +export namespace NameWarning { + export type Tag = "expired" | "available" | "reservedForCommunity" | "notRegistered" + + interface Interface { + type: Tag + } + + export interface Expired extends Interface { + type: "expired" + expiredAt: string // ISO-8601 timestamp + graceUntil?: string // ISO-8601 timestamp + } + + export interface Available extends Interface { + type: "available" + price: NamePrice + } + + export interface ReservedForCommunity extends Interface { + type: "reservedForCommunity" + } + + export interface NotRegistered extends Interface { + type: "notRegistered" + } +} + export type NetworkError = | NetworkError.ConnectError | NetworkError.TLSError @@ -4222,10 +4288,13 @@ export interface SimplexDomainClaim { proof?: SimplexDomainProof } -export type SimplexDomainError = SimplexDomainError.NoValidLink | SimplexDomainError.UnknownDomain +export type SimplexDomainError = + | SimplexDomainError.NoValidLink + | SimplexDomainError.UnknownDomain + | SimplexDomainError.NameWarning export namespace SimplexDomainError { - export type Tag = "noValidLink" | "unknownDomain" + export type Tag = "noValidLink" | "unknownDomain" | "nameWarning" interface Interface { type: Tag @@ -4238,6 +4307,11 @@ export namespace SimplexDomainError { export interface UnknownDomain extends Interface { type: "unknownDomain" } + + export interface NameWarning extends Interface { + type: "nameWarning" + nameWarning: NameWarning + } } export interface SimplexDomainProof { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index ab06a24f53..573731b5ad 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1481,10 +1481,12 @@ class ConnectionPlan_invitationLink(TypedDict): class ConnectionPlan_contactAddress(TypedDict): type: Literal["contactAddress"] contactAddressPlan: "ContactAddressPlan" + nameChange: NotRequired["NameChange"] class ConnectionPlan_groupLink(TypedDict): type: Literal["groupLink"] groupLinkPlan: "GroupLinkPlan" + nameChange: NotRequired["NameChange"] class ConnectionPlan_error(TypedDict): type: Literal["error"] @@ -1616,6 +1618,8 @@ class CtrlAppInfo(TypedDict): deviceName: str compression: bool +DomainVerification = Literal["failed", "verified", "moved"] + class DroppedMsg(TypedDict): brokerTs: str # ISO-8601 timestamp attempts: int # int @@ -1955,7 +1959,7 @@ class GroupInfo(TypedDict): rosterVersion: NotRequired[int] # int64 membersRequireAttention: int # int viaGroupLinkUri: NotRequired[str] - groupDomainVerified: NotRequired[bool] + groupDomainVerified: NotRequired["DomainVerification"] class GroupLink(TypedDict): userContactLinkId: int # int64 @@ -2204,7 +2208,7 @@ class LocalProfile(TypedDict): localBadge: NotRequired["LocalBadge"] localAlias: str contactDomain: NotRequired["SimplexDomainClaim"] - contactDomainVerified: NotRequired[bool] + contactDomainVerified: NotRequired["DomainVerification"] MemberCriteria = Literal["all"] @@ -2348,6 +2352,18 @@ MsgVerified = MsgVerified_signed | MsgVerified_sigMissing MsgVerified_Tag = Literal["signed", "sigMissing"] +class NameChange_lapsed(TypedDict): + type: Literal["lapsed"] + nameWarning: "NameWarning" + +class NameChange_moved(TypedDict): + type: Literal["moved"] + knownChat: "ChatInfo" + +NameChange = NameChange_lapsed | NameChange_moved + +NameChange_Tag = Literal["lapsed", "moved"] + class NameErrorType_NO_RESOLVER(TypedDict): type: Literal["NO_RESOLVER"] @@ -2362,6 +2378,34 @@ NameErrorType = NameErrorType_NO_RESOLVER | NameErrorType_NOT_FOUND | NameErrorT NameErrorType_Tag = Literal["NO_RESOLVER", "NOT_FOUND", "RESOLVER"] +class NamePrice(TypedDict): + amount: int # int64 + years: int # int + +class NameWarning_expired(TypedDict): + type: Literal["expired"] + expiredAt: str # ISO-8601 timestamp + graceUntil: NotRequired[str] # ISO-8601 timestamp + +class NameWarning_available(TypedDict): + type: Literal["available"] + price: "NamePrice" + +class NameWarning_reservedForCommunity(TypedDict): + type: Literal["reservedForCommunity"] + +class NameWarning_notRegistered(TypedDict): + type: Literal["notRegistered"] + +NameWarning = ( + NameWarning_expired + | NameWarning_available + | NameWarning_reservedForCommunity + | NameWarning_notRegistered +) + +NameWarning_Tag = Literal["expired", "available", "reservedForCommunity", "notRegistered"] + class NetworkError_connectError(TypedDict): type: Literal["connectError"] connectError: str @@ -2951,9 +2995,17 @@ class SimplexDomainError_noValidLink(TypedDict): class SimplexDomainError_unknownDomain(TypedDict): type: Literal["unknownDomain"] -SimplexDomainError = SimplexDomainError_noValidLink | SimplexDomainError_unknownDomain +class SimplexDomainError_nameWarning(TypedDict): + type: Literal["nameWarning"] + nameWarning: "NameWarning" -SimplexDomainError_Tag = Literal["noValidLink", "unknownDomain"] +SimplexDomainError = ( + SimplexDomainError_noValidLink + | SimplexDomainError_unknownDomain + | SimplexDomainError_nameWarning +) + +SimplexDomainError_Tag = Literal["noValidLink", "unknownDomain", "nameWarning"] class SimplexDomainProof(TypedDict): linkOwnerId: NotRequired[str] diff --git a/plans/2026-09-22-name-lookup-core-api.md b/plans/2026-09-22-name-lookup-core-api.md new file mode 100644 index 0000000000..46b912b16e --- /dev/null +++ b/plans/2026-09-22-name-lookup-core-api.md @@ -0,0 +1,80 @@ +# SimpleX name lookup — the core API + +The core half of #7525. Model: `plans/sketches/2026-09-18-names-lookup-flows.excalidraw`, states 1a–4d. Registry types: simplexmq `src/Simplex/Messaging/Names/Record.hs`. + +The types, scenarios, plan logic, CLI, app behaviour and decisions are in `plans/2026-09-28-name-warnings.md`. + +## Table of contents + +1. API +2. Local lookups +3. Resolve modes +4. Tests + +## 1. API + +```haskell +data ChatResponse + = ... + | CRConnectionPlan + { user :: User, + connLink :: ACreatedConnLink, + planSimplexName :: Maybe SimplexNameInfo, + otherSimplexName :: Maybe SimplexNameInfo, + connectionPlan :: ConnectionPlan + } + +data ChatEvent + = ... + | CEvtNameMoved {user :: User, contactIds :: [ContactId], groupIds :: [GroupId]} +``` + +`ConnectionPlan`, `NameChange`, `NameWarning`, `SimplexDomainError` and `DomainVerification` are in name-warnings §4. + +```haskell +connectPlan :: User -> AConnectTarget -> PlanResolveMode -> Maybe LinkOwnerSig + -> Maybe (Either ChatError NameRecord) + -> CM (ACreatedConnLink, Maybe SimplexNameInfo, Maybe SimplexNameInfo, ConnectionPlan) +``` + +A bare name's resolution is passed to each kind's plan, so the name is resolved once. + +Registration does not go through `ConnectionPlan`. + +## 2. Local lookups + +By name, before the comparison with the name's link: +- `getContactToConnect` (`Direct.hs`): `cp.contact_domain`, with `contact_domain_verified = 1`; +- `getGroupToConnect` (`Groups.hs`): `gp.group_domain`, with `group_domain_verified = 1`; +- `getUserContactLinkViaTarget` (`Profiles.hs`): the user's profile claim; +- `getGroupInfoViaUserTarget` (`Groups.hs`): `gp.group_domain`, with `group_domain_verified` other than 2 (moved). + +A prepared chat is set verified only when the link's profile claims the name (`APIPrepareContact`, `APIPrepareGroup`). + +## 3. Resolve modes + +| target | mode | answer | +|---|---|---| +| name | `PRMUnknown`, `PRMAllGroups` | resolved on every lookup, and compared with the local chat | +| name | `PRMNever` | the local chat, or `CENotResolvedLocally` | +| contact or group short link, local | `PRMUnknown`, `PRMNever` | the local plan | +| contact short link, local | `PRMAllGroups` | the local plan | +| group short link of a joined group | `PRMAllGroups` | the group, refreshed from its link data | +| group short link of the own group | `PRMAllGroups` | the local plan | +| contact or group short link, nothing local | `PRMUnknown`, `PRMAllGroups` | the link's plan | +| contact or group short link, nothing local | `PRMNever` | `CENotResolvedLocally` | + +The mode is ignored for invitation links. `allGroups` and `on` parse as `PRMAllGroups`. + +- `PRMNever`: the apps' name search. +- `PRMUnknown`: a tap on "Connect to …". +- `PRMAllGroups`: the directory service, with links. + +`CENotResolvedLocally` is discarded by both apps. + +## 4. Tests + +A name is re-pointed with `registerName`, or registered as expired with `registerExpiredName`. A registry query is detected by the next plan's output. The other tests are in name-warnings §10. +- the name moved: the new chat after it is prepared (`testPlanKnownNameNewChatOpened`); +- a chat prepared with a name its profile does not claim is left unverified (`testPrepareNameNotClaimed`); +- with `resolve=never`, the answer is taken from the store (`testPlanNameResolveNever`). diff --git a/plans/2026-09-28-name-warnings.md b/plans/2026-09-28-name-warnings.md new file mode 100644 index 0000000000..74846b52c0 --- /dev/null +++ b/plans/2026-09-28-name-warnings.md @@ -0,0 +1,325 @@ +# Name warnings in connection plans + +## Table of contents + +1. Summary +2. Terms +3. Scenarios and answers +4. Types +5. Plan logic +6. CLI +7. Apps +8. Name verification +9. Decisions +10. Tests + +## 1. Summary + +- A name's registration is classified in core by `nameRecordOrWarning`: the record of an unexpired name, or a `NameWarning`. +- With something local, the warning is in the local plan: `nameChange = Just (NCLapsed w)` on `CPContactAddress` or `CPGroupLink`, except `NWNotRegistered` (N11). +- With nothing local, the warning is the error `CESimplexDomainNotReady d (SDENameWarning w)` (N1). +- The apps show an alert exactly for a warning. They do not compare dates, lengths or registrations. +- A typed name (`@d`, `#d`) is planned for its kind only. A bare name (`d`) is resolved once and planned for one kind. +- The name is resolved on every lookup, except with `resolve=never` (N28). +- A local chat whose name leads to another link is answered with that link's plan and `NCMoved` (3c, N35). + +## 2. Terms + +- **name:** `d`, e.g. `bakery.simplex`. It is typed as `@d` (contact name), `#d` (channel name), or bare `d`. +- **kind:** contact or channel. `@d` has the contact kind, `#d` the channel kind. **The other kind** is the channel for `@d` and the contact for `#d`. +- **the name's link of a kind:** the first entry in the registry record's list for that kind (`nrSimplexContact`, `nrSimplexChannel`) that parses as a short link of that kind's type. Entries that do not parse are ignored. +- **links of local things:** + - **a chat's link:** the short link stored when the user connected or joined (`conn_short_link_to_connect`); + - **the own address's or own channel's link:** its `short_link_contact`; + - **the same link:** the same type, server host and port, and link key (`sameShortLinkContact`); the server's key hash is not compared (N27); + - **a new link:** the name's link differs from the local one; + - **leads to:** "the name leads to X" means the name's link of X's kind is X's link. +- **registry answers:** + - **registered:** `NRRegistered`; + - **unexpired:** registered, and `expires` is absent or not earlier than now; + - **expired:** registered, and `expires` is earlier than now; + - **available:** `NRAvailable`, and the label (the part before the TLD, without subnames) has at least `minLabelLength` characters; + - **reserved for community:** `NRReserved community`; + - **not registered:** `NRReserved` for another reason, or `NRAvailable` with a shorter label; + - **request failed:** the resolver request returns an error. +- **address:** a short link of the contact type, including business addresses. +- **own address:** the user's contact address. It counts for `d` when the user's profile claims `d`. +- **channel:** a public group of the channel type. +- **own channel:** a group the user has the group link for, whose profile claims `d`. +- **chat** (for a name, of a kind), not counting the own address or channel: + - contact kind: a contact that is not deleted, or a business chat the user is still a member of; + - channel kind: a joined or prepared channel the user is still a member of. + + In both cases the name `d` is verified, and a short link is stored. +- **local:** a chat, the own address or the own channel, of the kind, for the name. **Nothing local:** none of them. +- **from search, from a message:** the canvas entry points 1a and 1b. + +## 3. Scenarios and answers + +From `plans/sketches/2026-09-18-names-lookup-flows.excalidraw`. + +**A typed name `@d` or `#d`, of kind K** + +| Name | Local of kind K | Answer | Warning | Screen | +|---|---|---|---|---| +| unexpired, link L | nothing | L's plan | — | 2a | +| unexpired, link L at a contact or channel the user has, not verified for `d` | nothing | that chat, verified | — | 3a | +| unexpired, link L | chat or own at L | the local one | — | 3a, 4a, 4a′ | +| unexpired, link L | chat at another link | L's plan, `NCMoved` | — | 3c | +| unexpired, link L | own address at another link | L's plan | — | 2a | +| unexpired, link L | own channel at another link | L's plan, `NCMoved` | — | 3c | +| unexpired, link L whose profile does not claim `d` | nothing | error `SDEUnknownDomain` | — | 2g, 4b | +| unexpired, link L whose profile does not claim `d` | chat or own | the local one | — | 3a, 4a, 4a′ | +| unexpired, link L whose data cannot be fetched | nothing | error | — | 2h | +| unexpired, link L whose data cannot be fetched | chat or own at another link | the local one (N19) | — | 3a, 4a, 4a′ | +| unexpired, link L that cannot be joined yet (no relays, needs an app update, connecting) | chat or own channel at another link | L's plan, `NCMoved` (N35) | — | L's alert | +| unexpired, link L at another chat the user has | chat or own channel at another link | that chat, verified, `NCMoved` with the local chat moved (N35, N37) | — | 3a | +| unexpired, no link of kind K | nothing | error `SDENoValidLink` | — | 2f | +| unexpired, no link of kind K | chat or own | the local one | — | 3a, 4a, 4a′ | +| expired | nothing | error `SDENameWarning` | `NWExpired` | 2b | +| expired | chat | the chat | `NWExpired` | 3b, 1d | +| expired | own | own | `NWExpired` | 4c | +| available | nothing | error `SDENameWarning` | `NWAvailable` | 2c | +| available | chat | the chat | `NWAvailable` | 3d | +| available | own | own | `NWAvailable` | 4d | +| reserved for community | nothing | error `SDENameWarning` | `NWReservedForCommunity` | 2d | +| reserved for community | chat | the chat | `NWReservedForCommunity` | 3d | +| reserved for community | own | own | `NWReservedForCommunity` (N13) | 2d's alert | +| not registered | nothing | error `SDENameWarning` | `NWNotRegistered` | 2e | +| not registered | chat or own | the local one | — | 3a, 4a, 4a′ | +| request failed | chat or own | the local one | — | 3a, 4a, 4a′ | +| request failed | nothing | error | — | 2h | +| not asked: `resolve=never` | chat or own | the local one | — | 3a, 4a, 4a′ | +| not asked: `resolve=never` | nothing | error `CENotResolvedLocally` | — | — | + +`otherSimplexName` is set for bare names only. + +**A bare name `d`** + +The name is resolved once, and the record or the error is passed to each kind's plan. + +| Registry answer | Answer | `otherSimplexName` | +|---|---|---| +| unexpired, with a channel link | the channel's, as `#d`; the contact's, as `@d`, on an error in the channel's; the channel's error on errors in both | the other kind, when the name has a link of it | +| unexpired, with only a contact link | the contact's, as `@d` | — | +| unexpired, with no link | the local channel, else the local contact, else error `SDENoValidLink` | — | +| a warning | the local channel, else the local contact, else error `SDENameWarning` | — | +| request failed | the local channel, else the local contact, else the error | — | +| not asked: `resolve=never` | the local channel, else the local contact, else `CENotResolvedLocally` | — | + +For a channel without relays, or one that needs an app update, the channel's plan is the answer (N29). + +`otherSimplexName` is shown as the second button of 2a, 3c, 4a, 4a′, the "Repeat join request?" sheet, and a chat's, prepared contact's or own channel's alert. It is omitted from the no-relays, app-update and "You are already joining the group" alerts. With the button, the other kind's name is planned as a typed name. + +## 4. Types + +```haskell +data ConnectionPlan + = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan, nameChange :: Maybe NameChange} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan, nameChange :: Maybe NameChange} + | CPError {chatError :: ChatError} + +data NameChange + = NCLapsed {nameWarning :: NameWarning} + | NCMoved {knownChat :: AChatInfo} + +data NameWarning + = NWExpired {expiredAt :: UTCTime, graceUntil :: Maybe UTCTime} + | NWAvailable {price :: NamePrice} + | NWReservedForCommunity + | NWNotRegistered + +data NamePrice = NamePrice {amount :: USDCents, years :: Int} + +data SimplexDomainError + = SDENoValidLink + | SDEUnknownDomain + | SDENameWarning {nameWarning :: NameWarning} + +data DomainVerification + = DVFailed + | DVVerified + | DVMoved +``` + +- `nameChange` is how the name changed since the local chat was found by it: + - `NCLapsed`: the registration lapsed; the plan is the local one; + - `NCMoved`: the name leads to another link; the plan is that link's, and `knownChat` is the local chat (3c, N35, N37). +- `contactDomainVerified` and `groupDomainVerified` are `Maybe DomainVerification`, stored as 0 (failed), 1 (verified) and 2 (moved) (N38). +- `expiredAt` is present whenever a name is expired. A missing `graceUntil` is the dateless variant; a passed `graceUntil` is dropped (N30). +- `NamePrice` is the price of the 2-year term (N3). +- The domain of a lapsed name is `planSimplexName`. The error has it as `simplexDomain`. + +```haskell +nameRecordOrWarning :: SystemSeconds -> SimplexDomain -> NameRegistration -> Either NameWarning NameRecord +resolveConnectableName :: User -> NetworkRequestMode -> SimplexDomain -> CM NameRecord +``` + +- `nameRecordOrWarning` classifies the registration. The label length check is in it. +- `resolveConnectableName` resolves a name for a plan, and throws `CESimplexDomainNotReady d (SDENameWarning w)` for a warning. Other callers use `resolveNameRecord`. + +## 5. Plan logic + +**The local lookups.** `knownLinkPlans`, one per kind, in the `where` of `connectPlan`'s short link branches: +- **the contact kind:** the own address, else a contact, else a business chat; +- **the channel kind:** the own channel, else a joined or prepared channel. + +The result is the link and the local plan without its change. The known chat is the plan's chat (none for the own address). Only verified chats are found by name. + +**A typed name `@d` or `#d`** + +1. The name's kind is looked up locally. +2. With something local, `knownNamePlan` answers with the local plan with `resolve=never`. Otherwise the name is resolved to the name's link of the kind, or to an error: a warning (`SDENameWarning`), no link of the kind (`SDENoValidLink`), or a failed request. The answer is: + - **the same link:** the local plan; + - **another link L:** the plan for L, with `NCMoved` for a chat or own channel (N35), without a change for the own address (N9); the local plan on an error in L's plan (N12, N19); L's plan without a change when it is the local chat (N39); + - **a warning:** the local plan with `NCLapsed w`, except `NWNotRegistered` (N11); + - **another error:** the local plan. +3. With nothing local, the answer is `CENotResolvedLocally` with `resolve=never`. Otherwise the name is resolved, and the answer is the plan for the name's link, or the error. + +In the plan for a link L, the claim of L's profile is checked (`SDEUnknownDomain`), and a chat the user has at L is refreshed and verified. `knownChat` is then marked moved (N37). + +**A link target:** the local plan. With `resolve=allGroups`, a joined group found by the link is refreshed from its link data. + +**A bare name `d`** + +1. With `resolve=never`: `#d`, then `@d`, from the local lookups, else `CENotResolvedLocally`. +2. The name is resolved once by `resolveConnectableName`, and the record or the error is passed to each kind's plan. +3. An unexpired name with a channel link: `#d`; on an error, `@d`; on a second error, the channel's error. +4. An unexpired name with only a contact link: `@d`. +5. An unexpired name with no link, a warning, or a failed request: `#d`, then `@d`, from the local lookups, else the channel's error. +6. For an unexpired name, `otherSimplexName` is set to the other kind's name when the name has a link of it (`addOther`, N8, N22). + +`connectionPlanProceed` is false for a plan with `NCLapsed` (N18). + +## 6. CLI + +`viewConnectionPlan` takes `planSimplexName`. A `CPContactAddress` or `CPGroupLink` plan is followed by its `viewNameChange` line: + +- `NCLapsed`, by `viewNameWarning`: + - `SimpleX name bakery.simplex expired on 2027-06-24, its owner can renew it until 2027-09-22` + - `your SimpleX name alice.simplex expired on 2027-06-24, renew it before 2027-09-22` + - `SimpleX name bakery.simplex is no longer registered, available: $20 for 2 years` + - `your SimpleX name alice.simplex is no longer registered, available: $20 for 2 years` + - `SimpleX name privacy.simplex is reserved for community` +- `NCMoved`: + - `known contact @alice` + - `known group #club` + - `known channel #team` + - `known business #acme` + +The `your` lines are printed for `CAPOwnLink` and `GLPOwnLink` plans. A missing `graceUntil` drops the second clause of the expiry lines. + +A chat's name line shows its `DomainVerification`: +- `SimpleX name: @alice.simplex (verified)` +- `SimpleX name: @alice.simplex (verification failed)` +- `SimpleX name: @alice.simplex (moved)` +- `SimpleX name: @alice.simplex (unverified)`, for no status with a proof + +Errors are printed by `viewChatError`: +- `SimpleX name sunflower.simplex is available: $20 for 2 years` (`viewNameNotConnectable`) +- `SimpleX name bakery.simplex expired on 2027-06-24, its owner can renew it until 2027-09-22` +- `SimpleX name privacy.simplex is reserved for community` +- `SimpleX name acme.simplex is not registered` +- `SimpleX name boogaloo.simplex has no valid connection link` +- `SimpleX name acme.simplex is not included in the connection link's profile` +- `no matching chat found, name resolution is disabled` + +## 7. Apps + +- **Alert.** `showNameWarningAlert` maps `NameWarning` and `own` to title, message and action: + - actions: Renew, Register, Re-register, Connect to SimpleX team; + - Open chat when the plan has a local chat (1d), with OK; + - an available name: "Name no longer registered" with Open chat, "Name not registered" without it. +- **Where the alert is shown:** + - `NCLapsed`: by `planAndConnect`, with `own` from the plan's `isOwnLink`; + - `SDENameWarning`: by the connect error handler (`apiConnectResponseAlert`), with `own = false` and no Open chat. +- **Other plans.** A chat, prepared contact or own channel (`CAPKnown`, `CAPContactViaAddress`, `GLPKnown`, `GLPOwnLink`) shows its alert with the other kind's button (N14). For a pasted link, the list filter is shown instead of the alert. Every other plan shows its alert, with the other kind's button where §3 lists it. +- **`NCMoved` (3c, N10).** The plan's alert is shown, with Cancel. On a tap from the chat list search, `knownChat` is added to the list filter; Cancel keeps the filter. +- **Local chats (N32).** + - `planChat`: the plan's contact or group; + - `knownChat`: the chat of `NCMoved`; + - `localChats`: both. + + `localChats` are added to or updated in the chat list, and passed to `showLocalChats`. Open chat opens `planChat`. +- **Name status.** A moved name is shown with a cross in the secondary colour. A tap re-verifies it, as for a failed name. +- **Name search (N17, N31).** + - Chat list: while the text is a name, a debounced `resolve=never` lookup is made for its kind, or for `@d` and `#d` for a bare name. The list is filtered to the plans' local chats, which are added to or updated in the chat list. + - New chat sheet: nothing is looked up while typing. + - In both, the "Connect to …" button is shown while the text is a name. The name is planned with no filter. + - Chat list: the local chats of the button's plan are added to the list filter. The other kind's button leaves the filter unchanged. + - The search is cleared when a chat is opened or a connection is started from the button's flow. The new chat sheet is closed then. +- **Pasted links.** `filterKnownContact` and `filterKnownGroup` are passed. +- **Strings.** Prices: "%s for %d years". Dates: one long localized format (N25). + +## 8. Name verification + +One chat per name and kind is verified (N36). + +- `setOtherNameChatsMoved user chatInfo` is called after every write that sets a chat verified. The name is the chat's claim (`chatSimplexName`). The calls are in: + - `APIPrepareContact`, for a contact or a business chat; + - `APIPrepareGroup`; + - `APIVerifyContactDomain`; + - `APIVerifyGroupDomain`; + - `APISetPublicGroupAccess`; + - `APIChangePreparedContactUser` and `APIChangePreparedGroupUser`, for the new user, when the moved chat is verified; + - `updateContactFromLinkData` and `updateGroupFromLinkData`, when the chat is verified from the link data; + - the contact kind's plan, for a business chat at the name's link (`setPreparedGroupDomain`). +- The user's other verified chats of the name's kind are set moved (2): + - `setNameContactsMoved`: contacts, for a contact name; + - `setNameGroupsMoved`: business chats for a contact name, channels for a channel name. +- The ids of the chats set moved are returned by the two queries. `CEvtNameMoved {user, contactIds, groupIds}` is emitted when they are not empty. The apps set these chats moved, changing no other field. +- A chat is set moved only when another chat is verified for the name, not when a plan finds the name moved (N38). + +## 9. Decisions + +| # | Question | Answer | +|---|---|---| +| N1 | Where the warning is | with something local: `NCLapsed` in `nameChange` on `CPContactAddress`/`CPGroupLink`; with nothing local: error `CESimplexDomainNotReady d (SDENameWarning w)` | +| N2 | Own and chat variants | the wording is chosen from the plan: the own name (`CAPOwnLink`, `GLPOwnLink`) and a chat say "no longer registered" for an available name; the error says "available" | +| N3 | Price | the registry's per-year price for the label's length, times 2 years | +| N4 | An unexpired name with `reservedReason_ = community` | no warning | +| N5 | 3c | `NCMoved` in `nameChange`; a lapsed and a moved name exclude each other | +| N7 | The name no longer has a link of a chat's or own's kind | not reported | +| N8 | The other kind | offered for bare names only, whenever the name has a link of that kind | +| N9 | Own address or channel at another link than the name's | own channel: 3c, as for a chat; own address: the new link's plan without a change | +| N10 | 3c | the new link's alert, with Cancel and no move text; from the chat list search, `knownChat` is in the list filter, and Cancel keeps it | +| N11 | Not registered, with a chat or own | not reported | +| N12 | The new link does not claim the name, with a chat or own | the local one, no alert | +| N13 | Own name reserved for community after its registration ended | `NWReservedForCommunity`, 2d's alert | +| N14 | 3e | dropped: the other kind is offered by the second button of the chat's, prepared contact's or own channel's alert | +| N15 | The request failed | with a chat or own: the local one, no alert; with nothing: the error alert | +| N16 | Where the bare name's lookups are | the two local lookups (`knownLinkPlans`) are in the `where` of `connectPlan`'s short link branches; each kind of a bare name is planned through `connectPlan`, with the resolution already made | +| N17 | Name search (the "Connect to …" button) | no filter is passed: an alert is shown for every answer, with Open chat for a local chat; in the chat list, the plan's local chats are added to the list filter; the search is cleared when a chat is opened or a connection is started | +| N18 | `/c` with `nameChange` | `NCLapsed`: the plan is shown instead of connecting; `NCMoved`: connects when the plan allows | +| N19 | A chat, and the name's link data cannot be fetched | the chat, no alert | +| N22 | Bare name, the channel failed and the contact kind is planned | the channel is offered as the other kind | +| N24 | Expired without a grace date | "expired on ", without a renew clause | +| N25 | Dates in the alerts | one long localized format in both apps | +| N26 | Bot clients | `SimplexDomainError` has `nameWarning`; `contactDomainVerified` and `groupDomainVerified` change from a boolean to `DomainVerification` | +| N27 | Comparing the name's link with a local one | `sameShortLinkContact`: the server's key hash is not compared | +| N28 | When to resolve a name | on every lookup, except with `resolve=never` | +| N29 | Bare name, the channel has no relays or needs an app update | the channel's plan; the contact kind is offered when the name has a contact link | +| N30 | Expired, and the grace date has passed | the dateless variant (N24) | +| N31 | Name search | a debounced `resolve=never` lookup of the typed kind, or of both kinds for a bare name; the list is filtered to their local chats; the "Connect to …" button is shown while the text is a name | +| N32 | The local chat in the apps | `localChats` are added or updated; Open chat opens `planChat`; "You are already joining the group" keeps its alert and is not filtered | +| N34 | iOS against Kotlin | the name line in alerts, the own channel's "Your channel" title, a contact prepared at the name's address opened as in Kotlin, and the other kind's button on the connect and "Repeat join request?" sheets | +| N35 | A local chat's name leads to a link that cannot be joined yet, or to another chat the user has | that link's plan, or that chat's, with `NCMoved` | +| N36 | Chats verified for the same name and kind | one per user: `setOtherNameChatsMoved` sets the others moved and emits `CEvtNameMoved` with their ids | +| N37 | `knownChat` when the new link's chat is verified while planning | marked moved, as in the database | +| N38 | The moved status | set only when another chat is verified for the name; a moved chat is not found by name search | +| N39 | The name's link differs from the local chat's link, and L's plan is that chat | L's plan with the local link and without a change: the chat is found by L's connection request, and a changed link does not require joining again | + +## 10. Tests + +- **Unit tests:** `nameRecordOrWarning`, one per warning, plus the dateless, grace-passed, too-short, 2-year price and unexpired-community cases (`testNameRecordOrWarning`). +- **CLI tests**, in "connection plan: the name lookup answers": + - warnings with nothing local, as errors (`testConnectByNameNotFound`, `testPlanNameReservedOther`, `testPlanNameResolvedEveryCall`); + - a chat and own with a lapsed, reserved or unregistered name (`testPlanKnownNameAvailable`, `testPlanKnownNameReserved`, `testPlanOwnNameExpired`, `testPlanOwnNameAvailable`); + - a business chat (`testConnectByNameBusinessAndChannel`, `testPlanNameOtherKindBusiness`); + - every-lookup resolution (`testPlanKnownNameStale`, `testPlanNameResolvedEveryCall`); + - a moved name: 3c, another known chat, a known business chat, a new channel, a channel without relays (`testPlanKnownNameAddressChanged`, `testPlanKnownNameMovedToKnownChat`, `testPlanKnownNameMovedToBusinessChat`, `testPlanChannelNameMoved`, `testPlanChannelNameMovedNoRelays`); + - a new link whose data cannot be fetched (`testPlanKnownNameLinkFailed`); + - a bare name whose channel has no relays (`testPlanNameChannelNoRelays`); + - a link differing only in its key hash, or only in its server address (`testPlanNameLinkKeyHash`, `testPlanNameLinkServer`); + - a verified prepared contact moved to another user (`testPrepareNameChangeUser`). diff --git a/plans/sketches/2026-09-18-names-lookup-flows.excalidraw b/plans/sketches/2026-09-18-names-lookup-flows.excalidraw new file mode 100644 index 0000000000..2122168a50 --- /dev/null +++ b/plans/sketches/2026-09-18-names-lookup-flows.excalidraw @@ -0,0 +1,328 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw-jetbrains-plugin", + "elements": [ + {"id":"re858667946125","type":"rectangle","x":-14.233659758597241,"y":6.569381427044846,"width":2260,"height":96,"strokeColor":"transparent","fillStyle":"solid","roughness":0,"index":"a1","strokeWidth":2,"backgroundColor":"#eef0f2"}, + {"id":"te938484032189","type":"text","x":60,"y":24.68,"width":480.17,"height":32.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"SimpleX names - looking up a name","fontSize":26,"fontFamily":9,"index":"a2","strokeWidth":2,"lineHeight":1.25}, + {"id":"te881261405816","type":"text","x":60,"y":61.34,"width":620.15,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"changes against today: the chats found stay in the list, and only an actionable answer is an alert","fontSize":13,"fontFamily":9,"index":"a3","strokeWidth":2,"lineHeight":1.25}, + {"id":"te677015191161","type":"text","x":2099.67,"y":33.7,"width":100.33,"height":18.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"name lookup","fontSize":15,"fontFamily":9,"index":"a4","strokeWidth":2,"lineHeight":1.25}, + {"id":"te490030528915","type":"text","x":2120.91,"y":62.16,"width":79.09,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"editable SVG","fontSize":12,"fontFamily":9,"index":"a5","strokeWidth":2,"lineHeight":1.25}, + {"id":"re698762997543","type":"rectangle","x":-17.678157044262946,"y":223.704092339979,"width":209.04,"height":280.8,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"index":"a7","strokeWidth":2,"boundElements":[{"id":"ar909442594990","type":"arrow"}]}, + {"id":"te967184579851","type":"text","x":24.961842955737055,"y":250.774092339979,"width":34.63,"height":11.05,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Bakery","fontSize":9,"fontFamily":9,"index":"aC","strokeWidth":2,"lineHeight":1.25}, + {"id":"te854827695636","type":"text","x":24.961842955737055,"y":263.064092339979,"width":76.92,"height":9.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Your bread is ready","fontSize":8,"fontFamily":9,"index":"aD","strokeWidth":2,"lineHeight":1.25}, + {"id":"te93803011546","type":"text","x":8.321842955737054,"y":422.37409233997903,"width":117.69,"height":11.05,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Connect to bakery.simplex","fontSize":9,"fontFamily":9,"index":"aJ","strokeWidth":2,"lineHeight":1.25}, + {"id":"te321463628892","type":"text","x":8.321842955737054,"y":446.294092339979,"width":67.81,"height":11.05,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"bakery.simplex","fontSize":9,"fontFamily":9,"index":"aN","strokeWidth":2,"lineHeight":1.25}, + {"id":"te231930574466","type":"text","x":148.70184295573705,"y":446.294092339979,"width":32.26,"height":11.05,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Cancel","fontSize":9,"fontFamily":9,"index":"aP","strokeWidth":2,"lineHeight":1.25}, + {"id":"te146535658481","type":"text","x":-17.678157044262946,"y":511.844092339979,"width":115.33,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"1a. From search","fontSize":13,"fontFamily":9,"index":"aW","strokeWidth":2,"lineHeight":1.25}, + {"id":"te9023925185","type":"text","x":-17.678157044262946,"y":528.484092339979,"width":232.34,"height":68.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["search"],"text":"Typing filters to the chats found by this name,\nand Connect to resolves it, both as today.\nConnect to shows while the text is a name; each\ntap resolves the name, so an owner can always\ncheck a change.","fontSize":11,"fontFamily":9,"index":"aX","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist0","type":"rectangle","x":332.5,"y":298.4,"width":271.99,"height":132.06,"index":"aXV","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"strokeColor":"#868e96","boundElements":[{"id":"ar625193548486","type":"arrow"}]}, + {"id":"ellist0","type":"ellipse","x":546.49,"y":305.4,"width":10,"height":10,"index":"aXW","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"strokeColor":"#868e96"}, + {"id":"telist0","type":"text","x":559.49,"y":304.4,"width":40.17,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"aXX","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"strokeColor":"#868e96"}, + {"id":"re137734344677","type":"rectangle","x":332.5,"y":298.4,"width":209.99,"height":132.06,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"index":"b03","strokeWidth":2,"boundElements":[{"id":"ar909442594990","type":"arrow"}]}, + {"id":"te719661998578","type":"text","x":396.791,"y":314.78,"width":81.408,"height":14.82,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"Name expired","fontSize":12,"fontFamily":9,"index":"b04","strokeWidth":2,"lineHeight":1.25}, + {"id":"te79947822704","type":"text","x":342.41,"y":337.89,"width":190.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"bakery.simplex expired on 24 June 2027.","fontSize":9,"fontFamily":9,"index":"b05","strokeWidth":2,"lineHeight":1.25,"boundElements":[{"id":"ar909442594990","type":"arrow"},{"id":"ar625193548486","type":"arrow"}]}, + {"id":"te73085632245","type":"text","x":343.82,"y":350.90999999999997,"width":187.35,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"Its owner can renew it until 22 September","fontSize":9,"fontFamily":9,"index":"b07","strokeWidth":2,"lineHeight":1.25}, + {"id":"te000000000003","type":"text","x":423.93,"y":363.92999999999995,"width":27.13,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"2027.","fontSize":9,"fontFamily":9,"index":"b07V","strokeWidth":2,"lineHeight":1.25}, + {"id":"te272497230298","type":"text","x":428.75125,"y":400.01,"width":17.4875,"height":13.0,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["over","over-alert"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b08","strokeWidth":2,"lineHeight":1.25}, + {"id":"re832390381940over","type":"rectangle","x":507.49,"y":279.4,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"index":"b08G","strokeWidth":2}, + {"id":"te235708960196over","type":"text","x":514.44,"y":282.2,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"new","fontSize":10,"fontFamily":9,"index":"b08V","strokeWidth":2,"lineHeight":1.25}, + {"id":"te309994649535","type":"text","x":332.5,"y":437.84,"width":236.12,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"1c. One alert over the found chats","fontSize":13,"fontFamily":9,"index":"b09","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1071904305641","type":"text","x":332.5,"y":454.47999999999996,"width":268.15,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["over"],"text":"An actionable answer shows as one alert over the\nfiltered list. Dismissing it keeps the filter, so the\nchats found stay reachable.","fontSize":11,"fontFamily":9,"index":"b0A","strokeWidth":2,"lineHeight":1.25}, + {"id":"re649142848260","type":"rectangle","x":-19.776792930936608,"y":690,"width":209.04,"height":223.6,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"index":"b0E","strokeWidth":2,"boundElements":[{"id":"ar507222825509","type":"arrow"},{"id":"ar11920695389","type":"arrow"}]}, + {"id":"te657221303750","type":"text","x":-10.416792930936609,"y":711.9,"width":12.62,"height":22.1,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"\u2039","fontSize":18,"fontFamily":9,"index":"b0H","strokeWidth":2,"lineHeight":1.25}, + {"id":"te494244490744","type":"text","x":24.94320706906339,"y":718.11,"width":20,"height":11.05,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"Bob","fontSize":9,"fontFamily":9,"index":"b0K","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1059969009235","type":"text","x":84.22320706906339,"y":759.1,"width":77.42,"height":10.4,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"Where can I order?","fontSize":8,"fontFamily":9,"index":"b0N","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1036878777418","type":"text","x":-4.176792930936614,"y":798.62,"width":124.88,"height":20,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"Our bakery is on SimpleX now,\nfind it at","fontSize":8,"fontFamily":9,"index":"b0Q","strokeWidth":2,"lineHeight":1.25}, + {"id":"re363904878460","type":"rectangle","x":7.263207069063398,"y":881.36,"width":154.96,"height":18.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"index":"b0W","strokeWidth":2}, + {"id":"te763366176205","type":"text","x":15.583207069063391,"y":887.02,"width":38.46,"height":10.4,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"Message","fontSize":8,"fontFamily":9,"index":"b0X","strokeWidth":2,"lineHeight":1.25}, + {"id":"te123258408925","type":"text","x":-19.776792930936608,"y":920.94,"width":191.45,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"1b. Or a name in a message","fontSize":13,"fontFamily":9,"index":"b0c","strokeWidth":2,"lineHeight":1.25}, + {"id":"te724193777125","type":"text","x":-19.776792930936608,"y":937.58,"width":259.88,"height":68.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"One lookup, of the typed kind, on tap: no 3f,\nand 3a is today's alert to open the chat. The\nnew chat sheet's Connect to acts the same, but\nalso takes a name without @ or #: the chat's\nalert then has the other kind's button.","fontSize":11,"fontFamily":9,"index":"b0d","strokeWidth":2,"lineHeight":1.25}, + {"id":"re984646293193","type":"rectangle","x":332.5483165664566,"y":733.4455739971563,"width":209.99,"height":158.1,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"boundElements":[{"id":"ar507222825509","type":"arrow"}],"index":"b0e","strokeWidth":2}, + {"id":"te155911748011","type":"text","x":397.3283165664566,"y":749.8255739971563,"width":80.5,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"Name expired","fontSize":12,"fontFamily":9,"index":"b0f","strokeWidth":2,"lineHeight":1.25}, + {"id":"te567805733547","type":"text","x":342.4883165664566,"y":772.9355739971563,"width":190.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"bakery.simplex expired on 24 June 2027.","fontSize":9,"fontFamily":9,"index":"b0g","strokeWidth":2,"lineHeight":1.25}, + {"id":"te377552199405","type":"text","x":343.89831656645663,"y":785.9555739971563,"width":187.35,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"Its owner can renew it until 22 September","fontSize":9,"fontFamily":9,"index":"b0h","strokeWidth":2,"lineHeight":1.25}, + {"id":"te547358932146","type":"text","x":424.0083165664566,"y":798.9755739971563,"width":27.13,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"2027.","fontSize":9,"fontFamily":9,"index":"b0i","strokeWidth":2,"lineHeight":1.25}, + {"id":"te443785916671","type":"text","x":414.15,"y":835.0555739971563,"width":46.86,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"Open chat","fontSize":10,"fontFamily":9,"index":"b0j","strokeWidth":2,"lineHeight":1.25}, + {"id":"te696637277774","type":"text","x":429.4283165664566,"y":861.0955739971563,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b0k","strokeWidth":2,"lineHeight":1.25}, + {"id":"re832390381940","type":"rectangle","x":507.55,"y":714.4455739971563,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"index":"b0l","strokeWidth":2}, + {"id":"te235708960196","type":"text","x":514.5,"y":717.2455739971563,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"new","fontSize":10,"fontFamily":9,"index":"b0m","strokeWidth":2,"lineHeight":1.25}, + {"id":"te987142809072","type":"text","x":332.5483165664566,"y":918.8855739971564,"width":260.11,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"1d. From a message, with chats found","fontSize":13,"fontFamily":9,"index":"b0p","strokeWidth":2,"lineHeight":1.25}, + {"id":"te71774133772","type":"text","x":332.5483165664566,"y":935.5255739971564,"width":183.58,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["fromchat"],"text":"No list to filter, so the alert adds Open\nexisting chat.","fontSize":11,"fontFamily":9,"index":"b0q","strokeWidth":2,"lineHeight":1.25}, + {"id":"re911452534945","type":"rectangle","x":681.5956360464527,"y":140,"width":1490,"height":905,"strokeColor":"transparent","fillStyle":"solid","roughness":0,"index":"b0t","strokeWidth":2,"boundElements":[{"id":"ar625193548486","type":"arrow"},{"id":"ar11920695389","type":"arrow"},{"id":"ar967582753031","type":"arrow"},{"id":"ar998006064859","type":"arrow"},{"id":"ar996082616004","type":"arrow"},{"id":"ar1094370764209","type":"arrow"},{"id":"ar802878277106","type":"arrow"},{"id":"ar641649257354","type":"arrow"},{"id":"ar428625206945","type":"arrow"},{"id":"ar95247738220","type":"arrow"}],"backgroundColor":"#eef8fb"}, + {"id":"re573918155529","type":"rectangle","x":682.7272727272726,"y":1075.3513829104986,"width":1490,"height":985,"strokeColor":"transparent","fillStyle":"solid","roughness":0,"index":"b0u","strokeWidth":2,"boundElements":[{"id":"ar174135805467","type":"arrow"},{"id":"ar467310991719","type":"arrow"},{"id":"ar365433195697","type":"arrow"},{"id":"ar26133587117","type":"arrow"}],"backgroundColor":"#f4f3fe"}, + {"id":"re000000000004","type":"rectangle","x":687.9561340504179,"y":2079.15,"width":1490,"height":496,"strokeColor":"transparent","fillStyle":"solid","roughness":0,"index":"b0uV","strokeWidth":2,"backgroundColor":"#faf3ec"}, + {"id":"re787333526056","type":"rectangle","x":780,"y":330,"width":209.99,"height":177.36,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"boundElements":[{"id":"ar967582753031","type":"arrow"}],"index":"b0v","strokeWidth":2}, + {"id":"te307986224675","type":"text","x":864.58,"y":414.36,"width":40.9,"height":13.17,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"Bakery","fontSize":10,"fontFamily":9,"index":"b0z","strokeWidth":2,"lineHeight":1.25}, + {"id":"te714291846370","type":"text","x":847.55,"y":430.76,"width":74.95,"height":10.85,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"@bakery.simplex","fontSize":9,"fontFamily":9,"index":"b10","strokeWidth":2,"lineHeight":1.25}, + {"id":"te704456730618","type":"text","x":848.89,"y":450.83,"width":72.28,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"Open new chat","fontSize":10,"fontFamily":9,"index":"b11","strokeWidth":2,"lineHeight":1.25}, + {"id":"te295621174921","type":"text","x":867.05,"y":476.91,"width":35.95,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"Cancel","fontSize":10,"fontFamily":9,"index":"b13","strokeWidth":2,"lineHeight":1.25}, + {"id":"re872483412553","type":"rectangle","x":924.4,"y":311,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"index":"b14","strokeWidth":2}, + {"id":"te127256360443","type":"text","x":933.92,"y":313.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b15","strokeWidth":2,"lineHeight":1.25}, + {"id":"te591116195349","type":"text","x":780,"y":514.74,"width":133.55,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"2a. A new resource","fontSize":13,"fontFamily":9,"index":"b16","strokeWidth":2,"lineHeight":1.25}, + {"id":"te407194617630","type":"text","x":780,"y":531.38,"width":233.77,"height":96.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["newres"],"text":"A contact, or a channel. A name typed without @\nor # that leads to both shows the channel and\noffers the contact; if the channel fails, has no\nrelays or needs an app update, the contact\nshows alone (N22, N29). A channel shows its\nsubscriber count and Open channel. If you have\na chat at this link, it shows as 3a.","fontSize":11,"fontFamily":9,"index":"b17","strokeWidth":2,"lineHeight":1.25}, + {"id":"re281213594903","type":"rectangle","x":1070,"y":330,"width":209.99,"height":132.06,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"boundElements":[{"id":"ar998006064859","type":"arrow"}],"index":"b19","strokeWidth":2}, + {"id":"te828974930601","type":"text","x":1134.78,"y":346.38,"width":80.5,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"Name expired","fontSize":12,"fontFamily":9,"index":"b1A","strokeWidth":2,"lineHeight":1.25}, + {"id":"te41722046585","type":"text","x":1079.94,"y":369.49,"width":190.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"bakery.simplex expired on 24 June 2027.","fontSize":9,"fontFamily":9,"index":"b1B","strokeWidth":2,"lineHeight":1.25}, + {"id":"te855868247436","type":"text","x":1081.35,"y":382.51,"width":187.35,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"Its owner can renew it until 22 September","fontSize":9,"fontFamily":9,"index":"b1C","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1042377016001","type":"text","x":1161.46,"y":395.53,"width":27.13,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"2027.","fontSize":9,"fontFamily":9,"index":"b1D","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1097855693102","type":"text","x":1166.88,"y":431.61,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b1E","strokeWidth":2,"lineHeight":1.25}, + {"id":"re933698042467","type":"rectangle","x":1245,"y":311,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"index":"b1F","strokeWidth":2}, + {"id":"te248705394105","type":"text","x":1251.95,"y":313.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"new","fontSize":10,"fontFamily":9,"index":"b1G","strokeWidth":2,"lineHeight":1.25}, + {"id":"te983258046414","type":"text","x":1070,"y":469.44,"width":83.08,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"2b. Expired","fontSize":13,"fontFamily":9,"index":"b1H","strokeWidth":2,"lineHeight":1.25}, + {"id":"te494479197905","type":"text","x":1070,"y":486.08,"width":215.44,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["expired"],"text":"It does not connect while only its owner can\nrenew it, and a visitor can tell the owner.\nWithout a grace date, or once it has passed,\nonly the first sentence.","fontSize":11,"fontFamily":9,"index":"b1I","strokeWidth":2,"lineHeight":1.25}, + {"id":"re602913776992","type":"rectangle","x":1360,"y":330,"width":209.99,"height":145.08,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"boundElements":[{"id":"ar996082616004","type":"arrow"}],"index":"b1K","strokeWidth":2}, + {"id":"te553532468077","type":"text","x":1404.619592036599,"y":346.38,"width":106.90858459472656,"height":14.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"Name not registered","fontSize":12,"fontFamily":9,"index":"b1L","strokeWidth":2,"lineHeight":1.25}, + {"id":"te377717712024","type":"text","x":1388.1,"y":369.49,"width":153.87,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"sunflower.simplex is available for","fontSize":9,"fontFamily":9,"index":"b1M","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1080834200392","type":"text","x":1393.13,"y":382.51,"width":131.81642150878906,"height":11.625,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"registration for $100 for 2 years.","fontSize":9,"fontFamily":9,"index":"b1N","strokeWidth":2,"lineHeight":1.25}, + {"id":"te952288462429","type":"text","x":1443.48,"y":418.59,"width":43.09,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"Register","fontSize":10,"fontFamily":9,"index":"b1O","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1016368002885","type":"text","x":1456.88,"y":444.63,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b1P","strokeWidth":2,"lineHeight":1.25}, + {"id":"re267204074199","type":"rectangle","x":1504.4,"y":311,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"index":"b1Q","strokeWidth":2}, + {"id":"te98700466532","type":"text","x":1513.92,"y":313.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b1R","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1076048035035","type":"text","x":1360,"y":482.44,"width":94.67,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"2c. Available","fontSize":13,"fontFamily":9,"index":"b1S","strokeWidth":2,"lineHeight":1.25}, + {"id":"te289138893717","type":"text","x":1360,"y":499.08,"width":212.81,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["avail"],"text":"The registry's price for the label's length, for\nthe 2 years a registration lasts. Register\nopens the .testing how-to.","fontSize":11,"fontFamily":9,"index":"b1T","strokeWidth":2,"lineHeight":1.25}, + {"id":"re461925029464","type":"rectangle","x":1650,"y":330,"width":209.99,"height":158.1,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"boundElements":[{"id":"ar1094370764209","type":"arrow"}],"index":"b1W","strokeWidth":2}, + {"id":"te1011410885570","type":"text","x":1693.5041760394142,"y":345.26458400281524,"width":106.90858459472656,"height":14.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"Name not registered","fontSize":12,"fontFamily":9,"index":"b1X","strokeWidth":2,"lineHeight":1.25}, + {"id":"te956957970208","type":"text","x":1683.4,"y":369.49,"width":143.25,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"privacy.simplex is reserved for","fontSize":9,"fontFamily":9,"index":"b1Y","strokeWidth":2,"lineHeight":1.25}, + {"id":"te37795110354","type":"text","x":1660.24,"y":382.51,"width":189.58,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"community. Please contact SimpleX Chat","fontSize":9,"fontFamily":9,"index":"b1Z","strokeWidth":2,"lineHeight":1.25}, + {"id":"te753058109428","type":"text","x":1682.56,"y":395.53,"width":144.93,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"if you are interested in using it.","fontSize":9,"fontFamily":9,"index":"b1a","strokeWidth":2,"lineHeight":1.25}, + {"id":"te515999519271","type":"text","x":1692.99,"y":431.61,"width":124.07,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"Connect to SimpleX team","fontSize":10,"fontFamily":9,"index":"b1b","strokeWidth":2,"lineHeight":1.25}, + {"id":"te990060935705","type":"text","x":1746.88,"y":457.65,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b1c","strokeWidth":2,"lineHeight":1.25}, + {"id":"re746637013554","type":"rectangle","x":1794.4,"y":311,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"index":"b1d","strokeWidth":2}, + {"id":"te970429595740","type":"text","x":1803.92,"y":313.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b1e","strokeWidth":2,"lineHeight":1.25}, + {"id":"te759990831097","type":"text","x":1650,"y":495.44000000000005,"width":193.91,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"2d. Reserved for community","fontSize":13,"fontFamily":9,"index":"b1f","strokeWidth":2,"lineHeight":1.25}, + {"id":"te481987303758","type":"text","x":1650,"y":512.08,"width":223.34,"height":82.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["community"],"text":"The only reserved reason that is shown.\nConnect to SimpleX team opens the support\nchat. Also for your own name once it is back\nwith the registry; for your channel it adds\nOpen chat. A live name that is also reserved\nconnects as usual.","fontSize":11,"fontFamily":9,"index":"b1g","strokeWidth":2,"lineHeight":1.25}, + {"id":"re1000055747578","type":"rectangle","x":1940,"y":330,"width":209.99,"height":119.04,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"boundElements":[{"id":"ar802878277106","type":"arrow"}],"index":"b1j","strokeWidth":2}, + {"id":"te735778138446","type":"text","x":1980.15792804786,"y":345.8222920014077,"width":106.90858459472656,"height":14.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"Name not registered","fontSize":12,"fontFamily":9,"index":"b1k","strokeWidth":2,"lineHeight":1.25}, + {"id":"te957113106198","type":"text","x":1960,"y":369.49,"width":170.05,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"This SimpleX name is not registered.","fontSize":9,"fontFamily":9,"index":"b1l","strokeWidth":2,"lineHeight":1.25}, + {"id":"te331606863963","type":"text","x":1989.31,"y":382.51,"width":111.44,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"Please check the name.","fontSize":9,"fontFamily":9,"index":"b1m","strokeWidth":2,"lineHeight":1.25}, + {"id":"te601159873824","type":"text","x":2036.88,"y":418.59,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b1n","strokeWidth":2,"lineHeight":1.25}, + {"id":"re547921815949","type":"rectangle","x":2084.4,"y":311,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"index":"b1o","strokeWidth":2}, + {"id":"te126855740365","type":"text","x":2093.92,"y":313.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b1p","strokeWidth":2,"lineHeight":1.25}, + {"id":"te414916293817","type":"text","x":1940,"y":456.34,"width":234.45,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"2e. Any other name not registered","fontSize":13,"fontFamily":9,"index":"b1q","strokeWidth":2,"lineHeight":1.25}, + {"id":"te51408202449","type":"text","x":1940,"y":472.98,"width":235.7,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"Titled \"Name not found\" before: too short,\na subname, or reserved for another reason.\nWith a router below protocol v22, 2b to 2e\nstay \"Name not found\".","fontSize":11,"fontFamily":9,"index":"b1r","strokeWidth":2,"lineHeight":1.25}, + {"id":"re135561480222","type":"rectangle","x":780,"y":800,"width":209.99,"height":119.04,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"boundElements":[{"id":"ar641649257354","type":"arrow"}],"index":"b1t","strokeWidth":2}, + {"id":"te183356391990","type":"text","x":847.95,"y":816.38,"width":74.15,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"No valid link","fontSize":12,"fontFamily":9,"index":"b1u","strokeWidth":2,"lineHeight":1.25}, + {"id":"te504824938830","type":"text","x":794.69,"y":839.49,"width":180.67,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"The SimpleX name boogaloo.simplex is","fontSize":9,"fontFamily":9,"index":"b1v","strokeWidth":2,"lineHeight":1.25}, + {"id":"te880283032595","type":"text","x":805.86,"y":852.51,"width":158.33,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"registered, but it has no valid link.","fontSize":9,"fontFamily":9,"index":"b1w","strokeWidth":2,"lineHeight":1.25}, + {"id":"te271739204068","type":"text","x":876.88,"y":888.59,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b1x","strokeWidth":2,"lineHeight":1.25}, + {"id":"re369912509100","type":"rectangle","x":942.8,"y":781,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"index":"b1y","strokeWidth":2}, + {"id":"te1030000661806","type":"text","x":950.76,"y":783.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"today","fontSize":10,"fontFamily":9,"index":"b1z","strokeWidth":2,"lineHeight":1.25}, + {"id":"te577572310111","type":"text","x":780,"y":926.34,"width":112.04,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"2f. No valid link","fontSize":13,"fontFamily":9,"index":"b20","strokeWidth":2,"lineHeight":1.25}, + {"id":"te460383779539","type":"text","x":780,"y":942.98,"width":53.51,"height":13.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["nolink"],"text":"As today.","fontSize":11,"fontFamily":9,"index":"b21","strokeWidth":2,"lineHeight":1.25}, + {"id":"re287441848839","type":"rectangle","x":1070,"y":800,"width":209.99,"height":145.08,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"boundElements":[{"id":"ar428625206945","type":"arrow"}],"index":"b22","strokeWidth":2}, + {"id":"te1077088844085","type":"text","x":1120.99,"y":816.38,"width":108.07,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"Unconfirmed name","fontSize":12,"fontFamily":9,"index":"b23","strokeWidth":2,"lineHeight":1.25}, + {"id":"te168337406057","type":"text","x":1091.12,"y":839.49,"width":167.82,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"The SimpleX name foobar.simplex is","fontSize":9,"fontFamily":9,"index":"b24","strokeWidth":2,"lineHeight":1.25}, + {"id":"te312188434020","type":"text","x":1093.64,"y":852.51,"width":144.2451629638672,"height":11.625,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"registered, but not added to profile.","fontSize":9,"fontFamily":9,"index":"b25","strokeWidth":2,"lineHeight":1.25}, + {"id":"te267814176495","type":"text","x":1081.34,"y":865.53,"width":187.37,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"Please add it to your address or channel","fontSize":9,"fontFamily":9,"index":"b26","strokeWidth":2,"lineHeight":1.25}, + {"id":"te545911326424","type":"text","x":1110.94,"y":878.55,"width":128.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"profile, if you are the owner.","fontSize":9,"fontFamily":9,"index":"b27","strokeWidth":2,"lineHeight":1.25}, + {"id":"te936830220965","type":"text","x":1166.88,"y":914.63,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b28","strokeWidth":2,"lineHeight":1.25}, + {"id":"re787055134872","type":"rectangle","x":1232.8,"y":781,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"index":"b29","strokeWidth":2}, + {"id":"te964350518276","type":"text","x":1240.76,"y":783.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"today","fontSize":10,"fontFamily":9,"index":"b2A","strokeWidth":2,"lineHeight":1.25}, + {"id":"te697398476613","type":"text","x":1070,"y":952.44,"width":308.92,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"2g. Its address claims another name, or none","fontSize":13,"fontFamily":9,"index":"b2B","strokeWidth":2,"lineHeight":1.25}, + {"id":"te676583396524","type":"text","x":1070,"y":969.08,"width":252.36,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["claims"],"text":"As today, without saying which. Now also a\nchannel with no relays or needing an app\nupdate that does not claim the name; without\n@ or #, its contact shows instead if it can (2a).","fontSize":11,"fontFamily":9,"index":"b2C","strokeWidth":2,"lineHeight":1.25}, + {"id":"re950065089247","type":"rectangle","x":1360,"y":800,"width":209.99,"height":106.02,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"boundElements":[{"id":"ar95247738220","type":"arrow"}],"index":"b2D","strokeWidth":2}, + {"id":"te238965396889","type":"text","x":1408.17,"y":816.38,"width":113.72,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"SimpleX name error","fontSize":12,"fontFamily":9,"index":"b2E","strokeWidth":2,"lineHeight":1.25}, + {"id":"te596361612861","type":"text","x":1412.12,"y":839.49,"width":105.82,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"Resolver error: timeout","fontSize":9,"fontFamily":9,"index":"b2F","strokeWidth":2,"lineHeight":1.25}, + {"id":"te149708662361","type":"text","x":1456.88,"y":875.57,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b2G","strokeWidth":2,"lineHeight":1.25}, + {"id":"re690376357038","type":"rectangle","x":1522.8,"y":781,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"index":"b2H","strokeWidth":2}, + {"id":"te201445466085","type":"text","x":1530.76,"y":783.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"today","fontSize":10,"fontFamily":9,"index":"b2I","strokeWidth":2,"lineHeight":1.25}, + {"id":"te980605408490","type":"text","x":1360,"y":913.34,"width":155.05,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"2h. The request failed","fontSize":13,"fontFamily":9,"index":"b2J","strokeWidth":2,"lineHeight":1.25}, + {"id":"te855635414586","type":"text","x":1360,"y":929.98,"width":270.73,"height":96.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"As today, as are the alerts for no name server\nor resolver, a network error, a channel claiming\nthe name with no relays or needing an app\nupdate, and a link that cannot be fetched,\nunless a chat of yours matches or, without @\nor #, the contact shows (2a). Your own name\nnow gets the request and fetch errors, not 4a.","fontSize":11,"fontFamily":9,"index":"b2K","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist1","type":"rectangle","x":718,"y":1300,"width":60,"height":72,"index":"b2KV","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["list1"],"strokeColor":"#868e96"}, + {"id":"ellist1","type":"ellipse","x":722,"y":1307,"width":10,"height":10,"index":"b2KW","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["list1"],"strokeColor":"#868e96"}, + {"id":"telist1","type":"text","x":735,"y":1306,"width":40.17,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"b2KX","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["list1"],"strokeColor":"#868e96"}, + {"id":"re106891177647","type":"rectangle","x":780,"y":1300,"width":210,"height":72,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeStyle":"dashed","roughness":0,"index":"b2L","strokeWidth":2,"boundElements":[{"id":"ar174135805467","type":"arrow"}]}, + {"id":"te142761910350","type":"text","x":858.36,"y":1322.34,"width":53.28,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"no alert","fontSize":13,"fontFamily":9,"index":"b2M","strokeWidth":2,"lineHeight":1.25}, + {"id":"te426316295647","type":"text","x":807.4,"y":1341.98,"width":155.19,"height":13.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"the chat is in the filtered list","fontSize":11,"fontFamily":9,"index":"b2N","strokeWidth":2,"lineHeight":1.25}, + {"id":"re1034366287668","type":"rectangle","x":924.4,"y":1281,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"index":"b2O","strokeWidth":2}, + {"id":"te776486861809","type":"text","x":933.92,"y":1283.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b2P","strokeWidth":2,"lineHeight":1.25}, + {"id":"te688924689147","type":"text","x":780,"y":1379.34,"width":158.37,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"text":"3a. Nothing actionable","fontSize":13,"fontFamily":9,"index":"b2Q","strokeWidth":2,"lineHeight":1.25}, + {"id":"te103155488047","type":"text","x":780,"y":1395.98,"width":216.66,"height":82.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"text":"Still leads to your chat, or not found, no valid\nlink, another name, its new link fails or can't\nbe joined yet, or the request failed. If it now\nleads to another chat of yours, that one\nshows, also for your own name. From a\nmessage: today's alert to open the chat.","fontSize":11,"fontFamily":9,"index":"b2R","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist2","type":"rectangle","x":1008,"y":1300,"width":271.99,"height":132.06,"index":"b2RV","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["graced"],"strokeColor":"#868e96"}, + {"id":"ellist2","type":"ellipse","x":1012,"y":1307,"width":10,"height":10,"index":"b2RW","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["graced"],"strokeColor":"#868e96"}, + {"id":"telist2","type":"text","x":1025,"y":1306,"width":40.17,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"b2RX","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["graced"],"strokeColor":"#868e96"}, + {"id":"re399296144194","type":"rectangle","x":1070,"y":1300,"width":209.99,"height":132.06,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"boundElements":[{"id":"ar467310991719","type":"arrow"}],"index":"b2T","strokeWidth":2}, + {"id":"te697956584978","type":"text","x":1134.78,"y":1316.38,"width":80.5,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"Name expired","fontSize":12,"fontFamily":9,"index":"b2U","strokeWidth":2,"lineHeight":1.25}, + {"id":"te1097376107493","type":"text","x":1079.94,"y":1339.49,"width":190.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"bakery.simplex expired on 24 June 2027.","fontSize":9,"fontFamily":9,"index":"b2V","strokeWidth":2,"lineHeight":1.25}, + {"id":"te881280075806","type":"text","x":1081.35,"y":1352.51,"width":187.35,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"Its owner can renew it until 22 September","fontSize":9,"fontFamily":9,"index":"b2W","strokeWidth":2,"lineHeight":1.25}, + {"id":"te26225814743","type":"text","x":1161.46,"y":1365.53,"width":27.13,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"2027.","fontSize":9,"fontFamily":9,"index":"b2X","strokeWidth":2,"lineHeight":1.25}, + {"id":"te676679276527","type":"text","x":1166.88,"y":1401.61,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b2Y","strokeWidth":2,"lineHeight":1.25}, + {"id":"re530723706460","type":"rectangle","x":1245,"y":1281,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"index":"b2Z","strokeWidth":2}, + {"id":"te125346628948","type":"text","x":1251.95,"y":1283.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"new","fontSize":10,"fontFamily":9,"index":"b2a","strokeWidth":2,"lineHeight":1.25}, + {"id":"te175407384737","type":"text","x":1070,"y":1439.44,"width":83.08,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"3b. Expired","fontSize":13,"fontFamily":9,"index":"b2b","strokeWidth":2,"lineHeight":1.25}, + {"id":"te742715347101","type":"text","x":1070,"y":1456.08,"width":227.06,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"Anyone with a chat for the name sees that\nit expired; its owner sees 4c. The chat is\nfiltered in the list.","fontSize":11,"fontFamily":9,"index":"b2c","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist3","type":"rectangle","x":1298,"y":1300,"width":271.99,"height":210.8,"index":"b2cV","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["moved"],"strokeColor":"#868e96"}, + {"id":"ellist3","type":"ellipse","x":1302,"y":1307,"width":10,"height":10,"index":"b2cW","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["moved"],"strokeColor":"#868e96"}, + {"id":"telist3","type":"text","x":1315,"y":1306,"width":40.17,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"b2cX","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["moved"],"strokeColor":"#868e96"}, + {"id":"re83588957364","type":"rectangle","x":1360,"y":1300,"width":209.99,"height":210.8,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"boundElements":[{"id":"ar365433195697","type":"arrow"}],"index":"b2e","strokeWidth":2}, + {"id":"te45960574281","type":"text","x":1444.58,"y":1384.36,"width":40.9,"height":13.17,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"Bakery","fontSize":10,"fontFamily":9,"index":"b2i","strokeWidth":2,"lineHeight":1.25}, + {"id":"te447455144019","type":"text","x":1427.55,"y":1400.76,"width":74.95,"height":10.85,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"@bakery.simplex","fontSize":9,"fontFamily":9,"index":"b2j","strokeWidth":2,"lineHeight":1.25}, + {"id":"te869784825642","type":"text","x":1428.89,"y":1454.31,"width":72.28,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"Open new chat","fontSize":10,"fontFamily":9,"index":"b2m","strokeWidth":2,"lineHeight":1.25}, + {"id":"te94030513593","type":"text","x":1447.05,"y":1480.35,"width":35.95,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"Cancel","fontSize":10,"fontFamily":9,"index":"b2n","strokeWidth":2,"lineHeight":1.25}, + {"id":"re965099215536","type":"rectangle","x":1535,"y":1281,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"index":"b2o","strokeWidth":2}, + {"id":"te1033495344727","type":"text","x":1541.95,"y":1283.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"new","fontSize":10,"fontFamily":9,"index":"b2p","strokeWidth":2,"lineHeight":1.25}, + {"id":"te208733714710","type":"text","x":1360,"y":1518.14,"width":191.47,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"3c. Leads to a new address","fontSize":13,"fontFamily":9,"index":"b2q","strokeWidth":2,"lineHeight":1.25}, + {"id":"te377882734292","type":"text","x":1360,"y":1534.78,"width":235.78,"height":82.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"The new address's alert, as with nothing local.\nFrom search your chat stays filtered in the list,\nand Cancel keeps it. Also for your own channel;\nyour own address gets the new address's plan.\nA name without @ or # that also leads to the\nother kind offers it as a second button.","fontSize":11,"fontFamily":9,"index":"b2r","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist4","type":"rectangle","x":1588,"y":1300,"width":271.99,"height":145.08,"index":"b2rV","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["lost"],"strokeColor":"#868e96"}, + {"id":"ellist4","type":"ellipse","x":1592,"y":1307,"width":10,"height":10,"index":"b2rW","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["lost"],"strokeColor":"#868e96"}, + {"id":"telist4","type":"text","x":1605,"y":1306,"width":40.17,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"b2rX","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["lost"],"strokeColor":"#868e96"}, + {"id":"re819241109796","type":"rectangle","x":1650,"y":1300,"width":209.99,"height":145.08,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"boundElements":[{"id":"ar26133587117","type":"arrow"}],"index":"b2t","strokeWidth":2}, + {"id":"te1029996043274","type":"text","x":1678.36,"y":1316.38,"width":153.33,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"Name no longer registered","fontSize":12,"fontFamily":9,"index":"b2u","strokeWidth":2,"lineHeight":1.25}, + {"id":"te364627884637","type":"text","x":1683.96,"y":1339.49,"width":142.14,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"bakery.simplex is available for","fontSize":9,"fontFamily":9,"index":"b2v","strokeWidth":2,"lineHeight":1.25}, + {"id":"te406711063858","type":"text","x":1691.28,"y":1352.51,"width":127.5,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"registration for $100 for 2 years.","fontSize":9,"fontFamily":9,"index":"b2w","strokeWidth":2,"lineHeight":1.25}, + {"id":"te200855129744","type":"text","x":1733.48,"y":1388.59,"width":43.09,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"Register","fontSize":10,"fontFamily":9,"index":"b2x","strokeWidth":2,"lineHeight":1.25}, + {"id":"te204967153830","type":"text","x":1746.88,"y":1414.63,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b2y","strokeWidth":2,"lineHeight":1.25}, + {"id":"re332180152275","type":"rectangle","x":1825,"y":1281,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"index":"b2z","strokeWidth":2}, + {"id":"te975003079652","type":"text","x":1831.95,"y":1283.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"new","fontSize":10,"fontFamily":9,"index":"b30","strokeWidth":2,"lineHeight":1.25}, + {"id":"te994880692838","type":"text","x":1650,"y":1452.44,"width":278.29,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"3d. Available, or reserved for community","fontSize":13,"fontFamily":9,"index":"b31","strokeWidth":2,"lineHeight":1.25}, + {"id":"te297349636242","type":"text","x":1650,"y":1469.08,"width":230.49,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["lost"],"text":"The text and buttons of 2c or 2d. The chat\nstays in the list.","fontSize":11,"fontFamily":9,"index":"b32","strokeWidth":2,"lineHeight":1.25}, + {"id":"re445215284405","type":"rectangle","x":780,"y":2270,"width":209.99,"height":184.14,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"boundElements":[{"id":"ar537817736884","type":"arrow"}],"index":"b34","strokeWidth":2}, + {"id":"te83893938407","type":"text","x":823.56,"y":2286.38,"width":122.93,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"Connect to yourself?","fontSize":12,"fontFamily":9,"index":"b35","strokeWidth":2,"lineHeight":1.25}, + {"id":"te549755328637","type":"text","x":805.58,"y":2309.49,"width":158.9,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"This is your own SimpleX address!","fontSize":9,"fontFamily":9,"index":"b36","strokeWidth":2,"lineHeight":1.25}, + {"id":"te131860867684","type":"text","x":838.77,"y":2345.57,"width":92.51,"height":12.4,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"Use current profile","fontSize":10,"fontFamily":9,"index":"b37","strokeWidth":2,"lineHeight":1.25}, + {"id":"te635569632893","type":"text","x":822.98,"y":2371.61,"width":124.09,"height":12.4,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"Use new incognito profile","fontSize":10,"fontFamily":9,"index":"b38","strokeWidth":2,"lineHeight":1.25}, + {"id":"te118607224489","type":"text","x":842.49,"y":2397.65,"width":85.06,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"Join channel #alice","fontSize":10,"fontFamily":9,"index":"b39","strokeWidth":2,"lineHeight":1.25}, + {"id":"te486433536921","type":"text","x":867.05,"y":2423.69,"width":35.95,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"Cancel","fontSize":10,"fontFamily":9,"index":"b3A","strokeWidth":2,"lineHeight":1.25}, + {"id":"re833440366279","type":"rectangle","x":924.4,"y":2251,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"index":"b3B","strokeWidth":2}, + {"id":"te10203453787","type":"text","x":933.92,"y":2253.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b3C","strokeWidth":2,"lineHeight":1.25}, + {"id":"te301725264139","type":"text","x":780,"y":2461.44,"width":264.27,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"4a. Leads to you, and shows the name","fontSize":13,"fontFamily":9,"index":"b3D","strokeWidth":2,"lineHeight":1.25}, + {"id":"te595067966648","type":"text","x":780,"y":2478.08,"width":231.52,"height":82.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"As today for @alice.simplex. A name typed\nwithout @ or # that also leads to a channel\nshows this with Join channel when the channel\nfails. Also when the name has no link of its\nkind, is not registered, or its new link claims\nanother name or can't be joined yet.","fontSize":11,"fontFamily":9,"index":"b3E","strokeWidth":2,"lineHeight":1.25}, + {"id":"re111648126497","type":"rectangle","x":1070,"y":2270,"width":209.99,"height":145.08,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"boundElements":[{"id":"ar383945949602","type":"arrow"}],"index":"b3G","strokeWidth":2}, + {"id":"te712146501069","type":"text","x":1120.99,"y":2287.32,"width":108.07,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"Unconfirmed name","fontSize":12,"fontFamily":9,"index":"b3H","strokeWidth":2,"lineHeight":1.25}, + {"id":"te737005807706","type":"text","x":1098.19,"y":2310.43,"width":139.5,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"The SimpleX name alice.simplex is","fontSize":9,"fontFamily":9,"index":"b3I","strokeWidth":2,"lineHeight":1.25}, + {"id":"te421100556529","type":"text","x":1099.04,"y":2323.45,"width":139.5,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"registered, but not added to profile.","fontSize":9,"fontFamily":9,"index":"b3J","strokeWidth":2,"lineHeight":1.25}, + {"id":"te421593695898","type":"text","x":1090.21,"y":2336.47,"width":161.0,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"Please add it to your address or channel","fontSize":9,"fontFamily":9,"index":"b3K","strokeWidth":2,"lineHeight":1.25}, + {"id":"te655459991027","type":"text","x":1166.88,"y":2385.57,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b3M","strokeWidth":2,"lineHeight":1.25}, + {"id":"re545225797687","type":"rectangle","x":1232.8,"y":2251.94,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"index":"b3N","strokeWidth":2}, + {"id":"te440349073619","type":"text","x":1240.76,"y":2254.74,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"today","fontSize":10,"fontFamily":9,"index":"b3O","strokeWidth":2,"lineHeight":1.25}, + {"id":"te581390719059","type":"text","x":1070,"y":2423.38,"width":195.61,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"4b. Leads to you, not shown","fontSize":13,"fontFamily":9,"index":"b3P","strokeWidth":2,"lineHeight":1.25}, + {"id":"te299166426085","type":"text","x":1070,"y":2440.02,"width":230.72,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"As today: the 2g alert, whose text also\naddresses the owner. Adding the name stays in\naddress and channel settings.","fontSize":11,"fontFamily":9,"index":"b3Q","strokeWidth":2,"lineHeight":1.25}, + {"id":"re27041288037","type":"rectangle","x":1360,"y":2270,"width":209.99,"height":158.1,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"boundElements":[{"id":"ar534696206681","type":"arrow"}],"index":"b3S","strokeWidth":2}, + {"id":"te641002492075","type":"text","x":1399.8938769933684,"y":2286.38,"width":120.92106628417969,"height":14.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"Your name has expired","fontSize":12,"fontFamily":9,"index":"b3T","strokeWidth":2,"lineHeight":1.25}, + {"id":"te447349136667","type":"text","x":1376.09,"y":2309.49,"width":177.88,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"Your name alice.simplex expired on 24","fontSize":9,"fontFamily":9,"index":"b3U","strokeWidth":2,"lineHeight":1.25}, + {"id":"te57509421242","type":"text","x":1369.39,"y":2322.51,"width":191.27,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"June 2027. Renew it before 22 September","fontSize":9,"fontFamily":9,"index":"b3V","strokeWidth":2,"lineHeight":1.25}, + {"id":"te437005892001","type":"text","x":1451.46,"y":2335.53,"width":27.13,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"2027.","fontSize":9,"fontFamily":9,"index":"b3W","strokeWidth":2,"lineHeight":1.25}, + {"id":"te798815682183","type":"text","x":1448.54,"y":2371.61,"width":32.97,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"Renew","fontSize":10,"fontFamily":9,"index":"b3X","strokeWidth":2,"lineHeight":1.25}, + {"id":"te876489026864","type":"text","x":1456.88,"y":2397.65,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b3Y","strokeWidth":2,"lineHeight":1.25}, + {"id":"re792118738511","type":"rectangle","x":1535,"y":2251,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"index":"b3Z","strokeWidth":2}, + {"id":"te833108413170","type":"text","x":1541.95,"y":2253.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"new","fontSize":10,"fontFamily":9,"index":"b3a","strokeWidth":2,"lineHeight":1.25}, + {"id":"te109621994091","type":"text","x":1360,"y":2435.44,"width":129.41,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"4c. Yours, expired","fontSize":13,"fontFamily":9,"index":"b3b","strokeWidth":2,"lineHeight":1.25}, + {"id":"te999536547743","type":"text","x":1360,"y":2452.08,"width":235.63,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownexp"],"text":"Renew opens the how-to until the wallet lands,\nthen renews here. Without a grace date, or once\nit has passed, only the first sentence. For your\nchannel it adds Open chat.","fontSize":11,"fontFamily":9,"index":"b3c","strokeWidth":2,"lineHeight":1.25}, + {"id":"re775686655556","type":"rectangle","x":1650,"y":2270,"width":209.99,"height":158.1,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"boundElements":[{"id":"ar51174435892","type":"arrow"}],"index":"b3e","strokeWidth":2}, + {"id":"te457947658010","type":"text","x":1687.9,"y":2286.38,"width":134.25,"height":14.72,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"Your name has expired","fontSize":12,"fontFamily":9,"index":"b3f","strokeWidth":2,"lineHeight":1.25}, + {"id":"te945767504999","type":"text","x":1670.28,"y":2309.49,"width":169.5,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"Your name alice.simplex is no longer","fontSize":9,"fontFamily":9,"index":"b3g","strokeWidth":2,"lineHeight":1.25}, + {"id":"te169771895521","type":"text","x":1661.91,"y":2322.51,"width":186.23,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"registered. It is available for registration","fontSize":9,"fontFamily":9,"index":"b3h","strokeWidth":2,"lineHeight":1.25}, + {"id":"te152225942241","type":"text","x":1714.78,"y":2335.53,"width":80.5,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"for $100 for 2 years.","fontSize":9,"fontFamily":9,"index":"b3i","strokeWidth":2,"lineHeight":1.25}, + {"id":"te659086583391","type":"text","x":1726.94,"y":2371.61,"width":56.18,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"Re-register","fontSize":10,"fontFamily":9,"index":"b3j","strokeWidth":2,"lineHeight":1.25}, + {"id":"te749824996886","type":"text","x":1746.88,"y":2397.65,"width":16.29,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"OK","fontSize":10,"fontFamily":9,"index":"b3k","strokeWidth":2,"lineHeight":1.25}, + {"id":"re819385127968","type":"rectangle","x":1825,"y":2251,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"index":"b3l","strokeWidth":2}, + {"id":"te154608663323","type":"text","x":1831.95,"y":2253.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"new","fontSize":10,"fontFamily":9,"index":"b3m","strokeWidth":2,"lineHeight":1.25}, + {"id":"te140592682293","type":"text","x":1650,"y":2435.44,"width":169.96,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"4d. Yours, now available","fontSize":13,"fontFamily":9,"index":"b3n","strokeWidth":2,"lineHeight":1.25}, + {"id":"te550244875604","type":"text","x":1650,"y":2452.08,"width":264.16,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notyours"],"text":"Re-register opens the how-to until the wallet\nlands. For your channel it adds Open chat.","fontSize":11,"fontFamily":9,"index":"b3o","strokeWidth":2,"lineHeight":1.25}, + {"id":"te529883788752","type":"text","x":-19.776792930936608,"y":153.6,"width":332.92,"height":25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Search, or a name in a message","fontSize":20,"fontFamily":9,"index":"b42","strokeWidth":2,"lineHeight":1.25}, + {"id":"te370271847720","type":"text","x":-19.776792930936608,"y":180.16,"width":324.76,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"the chats found stay; an actionable answer is one alert","fontSize":12,"fontFamily":9,"index":"b43","strokeWidth":2,"lineHeight":1.25}, + {"id":"te149227894907","type":"text","x":780,"y":143.6,"width":274.35,"height":25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"No local chat for the name","fontSize":20,"fontFamily":9,"index":"b44","strokeWidth":2,"lineHeight":1.25}, + {"id":"te880787612123","type":"text","x":780,"y":170.16,"width":293.06,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"the name is new to you: the alert shows on its own","fontSize":12,"fontFamily":9,"index":"b45","strokeWidth":2,"lineHeight":1.25}, + {"id":"te905418546719","type":"text","x":772.7272727272727,"y":1084.5090909090907,"width":278.19,"height":25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"A local chat has this name","fontSize":20,"fontFamily":9,"index":"b46","strokeWidth":2,"lineHeight":1.25}, + {"id":"te845115350481","type":"text","x":774.5454545454545,"y":1112.8872727272728,"width":771.37,"height":15.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"only an actionable alert shows over the filtered chats; with a local chat it adds Open chat; Connect to shows while the text is a name","fontSize":12,"fontFamily":9,"index":"b47","strokeWidth":2,"lineHeight":1.25}, + {"id":"te822161367289","type":"text","x":780,"y":2088.6,"width":156,"height":25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Your own name","fontSize":20,"fontFamily":9,"index":"b48","strokeWidth":2,"lineHeight":1.25}, + {"id":"te166687152361","type":"text","x":780,"y":2115.16,"width":389.11,"height":15.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"your address or channel claims the name, or the name leads to it","fontSize":12,"fontFamily":9,"index":"b49","strokeWidth":2,"lineHeight":1.25}, + {"id":"te921930439761","type":"text","x":60,"y":2612.98,"width":1706.17,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Tags compare with the app before this change (643a9afad): today, changed, new. The three tinted bands split the answers: no local chat for the name, a local chat, or your own name. Register, Renew and Re-register open simplex.domains until the wallet (simplex-chat#7475) lands. Design document, not screenshots: alerts are schematic; iOS shows\n4a and 4a\u2032 as action sheets and pairs short buttons side by side. A lookup returns the chats found on the device and, on Connect to or a tapped name, one resolution from the registry. The API is in plans/2026-09-28-name-warnings.md. Registering,\nowning, recovering and giving names are separate flows.","fontSize":11,"fontFamily":9,"index":"b4A","strokeWidth":2,"lineHeight":1.25}, + {"id":"te719096292165","type":"text","x":237.33057968099348,"y":341.55499999999995,"width":53.20068359375,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"Connect to","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar909442594990","index":"b4C","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar909442594990","type":"arrow","x":196.361842955737,"y":364.42999999999995,"width":135.138157044263,"height":0,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te719096292165","type":"text"}],"points":[[0,0],[135.138157044263,0]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4D","startBinding":{"elementId":"re698762997543","fixedPoint":[1.023918867202449,0.49964387464387444],"focus":0,"gap":0},"endBinding":{"elementId":"re137734344677","fixedPoint":[-0.004762,0.5],"focus":0,"gap":1}}, + {"id":"te597337641794","type":"text","x":232.45,"y":806.92,"width":56.87451171875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"chats found","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar507222825509","index":"b4E","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar507222825509","type":"arrow","x":199.2231269090634,"y":813.7999412,"width":123.3252558673932,"height":1.3543268028437296,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te597337641794","type":"text"}],"points":[[0,0],[56.00008015999998,0],[56.00008015999998,-1.3543268028437296],[123.3252558673932,-1.3543268028437296]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":[{"index":2,"start":[56.00008015999998,0],"end":[56.00008015999998,-1.3543268028437296]}],"endBinding":{"elementId":"re984646293193","focus":0,"gap":10,"fixedPoint":[-0.047621,0.499684]},"index":"b4F","startBinding":{"elementId":"re649142848260","focus":0,"gap":10,"fixedPoint":[1.047646,0.553667]}}, + {"id":"te509749597660","type":"text","x":698.55,"y":362.4,"width":53.8076171875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"the answer","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","index":"b4G","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar625193548486","type":"arrow","x":610.49,"y":391.2664916007193,"width":26.15,"height":0.09643745591267816,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"points":[[0,0],[26.15,0.1]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4H","startBinding":{"elementId":"relist0","fixedPoint":[1.02206,0.703214],"focus":0,"gap":0},"endBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[-26.187111437291986,0.077528],"focus":0,"gap":1}}, + {"id":"te1001643326240","type":"text","x":306.75,"y":997.1,"width":83.76220703125,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"the same answer","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar11920695389","index":"b4I","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar11920695389","type":"arrow","x":199.2231269090634,"y":889.599894,"width":432.13147298422797,"height":147.83927408611544,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te1001643326240","type":"text"}],"points":[[0,0],[60.12854667662532,0],[60.12854667662532,147.83927408611544],[432.13147298422797,147.83927408611544]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":[{"index":2,"start":[60.12854667662532,0],"end":[60.12854667662532,147.83927408611544]}],"index":"b4J","startBinding":{"elementId":"re649142848260","focus":0,"gap":10,"fixedPoint":[1.047646,0.892665]},"endBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[-54.06464610022175,0.355091],"focus":0,"gap":0},"startIsSpecial":false,"endIsSpecial":false}, + {"id":"te723091252074","type":"text","x":846.55,"y":296,"width":66.0322265625,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"new resource","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar967582753031","index":"b4M","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar967582753031","type":"arrow","x":653.4143230848516,"y":268.0237888004906,"width":231.58567883514831,"height":35.97618879950937,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te723091252074","type":"text"}],"points":[[0,0],[231.58567883514831,0],[231.58567883514831,35.97618879950937]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"el687117614750","focus":0,"gap":40,"fixedPoint":[0.499496,-0.665995]},"index":"b4N","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[33.641197231061994,0.02454],"focus":0,"gap":1}}, + {"id":"te484082649543","type":"text","x":1152.65,"y":296,"width":36.07763671875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"expired","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar998006064859","index":"b4O","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar998006064859","type":"arrow","x":654.1014419175771,"y":252.90415424308264,"width":520.7935580824229,"height":72.09584575691736,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te484082649543","type":"text"}],"points":[[0,0],[520.7935580824229,0],[520.7935580824229,72.09584575691736]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re281213594903","focus":0,"gap":26,"fixedPoint":[0.5001,-0.19688]},"index":"b4P","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[36.37306907594756,0.018045],"focus":0,"gap":0}}, + {"id":"te308865595058","type":"text","x":1437.74,"y":296,"width":43.419921875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"available","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar996082616004","index":"b4Q","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar996082616004","type":"arrow","x":654.3727284384416,"y":238.61256062981528,"width":810.5222715615583,"height":86.38743937018472,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te308865595058","type":"text"}],"points":[[0,0],[810.5222715615583,0],[810.5222715615583,86.38743937018472]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re602913776992","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b4R","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[37.451659813021756,0.011905],"focus":0,"gap":0}}, + {"id":"te241882332848","type":"text","x":1715.27,"y":282.25,"width":58.7,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"reserved for\ncommunity","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar1094370764209","index":"b4S","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar1094370764209","type":"arrow","x":654.3344055251486,"y":226.97227550316637,"width":1100.5605944748513,"height":98.02772449683363,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te241882332848","type":"text"}],"points":[[0,0],[1100.5605944748513,0],[1100.5605944748513,98.02772449683363]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re461925029464","focus":0,"gap":26,"fixedPoint":[0.5001,-0.164453]},"index":"b4T","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[37.29929419174471,0.006904],"focus":0,"gap":0}}, + {"id":"te91260776015","type":"text","x":2004.11,"y":296,"width":67.26220703125,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"not registered","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar802878277106","index":"b4U","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar802878277106","type":"arrow","x":654.3446326810239,"y":215.66310972169384,"width":1390.550367318976,"height":109.33689027830616,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te91260776015","type":"text"}],"points":[[0,0],[1390.550367318976,0],[1390.550367318976,109.33689027830616]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re1000055747578","focus":0,"gap":26,"fixedPoint":[0.5001,-0.218414]},"index":"b4V","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[37.339955687047116,0.002045],"focus":0,"gap":0}}, + {"id":"te812586167565","type":"text","x":848.99,"y":766,"width":57.47607421875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"no valid link","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar641649257354","index":"b4X","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar641649257354","type":"arrow","x":656.4252420483435,"y":746.420902489336,"width":228.46975795165645,"height":48.57909751066404,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te812586167565","type":"text"}],"points":[[0,0],[228.46975795165645,0],[228.46975795165645,48.57909751066404]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re135561480222","focus":0,"gap":26,"fixedPoint":[0.5001,-0.218414]},"index":"b4Y","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[45.61211747344496,0.230066],"focus":0,"gap":0}}, + {"id":"te652132270794","type":"text","x":1115.2,"y":766,"width":102.1044921875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"claims another name","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar428625206945","index":"b4Z","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar428625206945","type":"arrow","x":658.4658656092469,"y":728.4934704931276,"width":516.4291343907531,"height":66.50652950687243,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te652132270794","type":"text"}],"points":[[0,0],[516.4291343907531,0],[516.4291343907531,66.50652950687243]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re287441848839","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b4a","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[53.72530224809285,0.222364],"focus":0,"gap":0}}, + {"id":"te940086299666","type":"text","x":1424.45,"y":766,"width":66.04296875,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"request failed","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar95247738220","index":"b4b","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar95247738220","type":"arrow","x":656.0246311710983,"y":710.5660384969194,"width":808.8703688289016,"height":84.43396150308058,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te940086299666","type":"text"}],"points":[[0,0],[808.8703688289016,0],[808.8703688289016,84.43396150308058]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re950065089247","focus":0,"gap":26,"fixedPoint":[0.5001,-0.245237]},"index":"b4c","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[44.019354294534494,0.214662],"focus":0,"gap":0}}, + {"id":"te25625561850","type":"text","x":830.56,"y":1251.25,"width":89.78,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"@?bakery.simplex\nnothing actionable","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar174135805467","index":"b4e","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar174135805467","type":"arrow","x":655,"y":1249,"width":229.89999999999998,"height":46,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te25625561850","type":"text"}],"points":[[0,0],[229.89999999999998,0],[229.89999999999998,46]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b4f","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945588718043346,0.44598],"focus":0,"gap":0},"endBinding":{"elementId":"re106891177647","focus":0,"gap":26,"fixedPoint":[0.5001,-0.361111]}}, + {"id":"te337356006311","type":"text","x":1125.78,"y":1252.25,"width":89.82,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"@?bakery.simplex\nexpired","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar467310991719","index":"b4g","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar467310991719","type":"arrow","x":655,"y":1232,"width":519.895,"height":63,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te337356006311","type":"text"}],"points":[[0,0],[519.895,0],[519.895,63]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re399296144194","focus":0,"gap":26,"fixedPoint":[0.5001,-0.19688]},"index":"b4h","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945588718043346,0.438676],"focus":0,"gap":0}}, + {"id":"te114834797981","type":"text","x":1398.54,"y":1252.25,"width":113.13,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"@?bakery.simplex\nleads to a new address","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar365433195697","index":"b4i","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar365433195697","type":"arrow","x":658.7272727272726,"y":1220,"width":806.1677272727273,"height":75,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te114834797981","type":"text"}],"points":[[0,0],[806.1677272727273,0],[806.1677272727273,75]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"el430900442039","focus":0,"gap":40,"fixedPoint":[0.499496,-0.665995]},"index":"b4j","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[54.76461409893341,0.433521],"focus":0,"gap":0}}, + {"id":"te103697166175","type":"text","x":1686.45,"y":1252.25,"width":115.55,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"@?bakery.simplex\navailable, or community","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar26133587117","index":"b4k","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar26133587117","type":"arrow","x":655,"y":1209,"width":1099.995,"height":65,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te103697166175","type":"text"}],"points":[[0,0],[1099.995,0],[1099.995,65]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re819241109796","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b4l","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945589,0.428795],"focus":0,"gap":0}}, + {"id":"te371607245649","type":"text","x":839.19,"y":2222.25,"width":80.22,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"@?alice.simplex\nyours, shown","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar537817736884","index":"b4n","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar537817736884","type":"arrow","x":652.0393054822434,"y":2214.66,"width":232.85569451775655,"height":50.343652410848335,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te371607245649","type":"text"}],"points":[[0,0],[232.85569451775655,0],[232.85569451775655,50.343652410848335]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re445215284405","focus":0,"gap":26,"fixedPoint":[0.5001,-0.141197]},"index":"b4o","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[28.1743526390025,0.860839],"focus":0,"gap":0}}, + {"id":"te369230914497","type":"text","x":1126.05,"y":2236,"width":83.1552734375,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"yours, not shown","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar383945949602","index":"b4p","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar383945949602","type":"arrow","x":655.2617416613341,"y":2199.17,"width":519.6332583386659,"height":65.8252175528512,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te369230914497","type":"text"}],"points":[[0,0],[519.6332583386659,0],[519.6332583386659,65.8252175528512]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re111648126497","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b4q","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[40.98623065823873,0.854184],"focus":0,"gap":0}}, + {"id":"te80405804120","type":"text","x":1423.05,"y":2236,"width":69.08837890625,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"yours, expired","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar534696206681","index":"b4r","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar534696206681","type":"arrow","x":654.7980909018529,"y":2182.22,"width":810.0969090981471,"height":82.78121747028285,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te80405804120","type":"text"}],"points":[[0,0],[810.0969090981471,0],[810.0969090981471,82.78121747028285]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re27041288037","focus":0,"gap":26,"fixedPoint":[0.5001,-0.164453]},"index":"b4s","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.14283124119408,0.846903],"focus":0,"gap":0}}, + {"id":"te1085880398720","type":"text","x":1708.14,"y":2236,"width":76.4306640625,"height":13.75,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"yours, available","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar51174435892","index":"b4t","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar51174435892","type":"arrow","x":655.7620951733107,"y":2164.71,"width":1099.1329048266894,"height":100.29322106450195,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te1085880398720","type":"text"}],"points":[[0,0],[1099.1329048266894,0],[1099.1329048266894,100.29322106450195]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re775686655556","focus":0,"gap":26,"fixedPoint":[0.5001,-0.164453]},"index":"b4u","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[42.975554202809334,0.83938],"focus":0,"gap":0}}, + {"id":"re834577760764","type":"rectangle","x":-9.358157044262953,"y":441.064092339979,"width":153.92,"height":18.72,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["search"],"index":"b4v"}, + {"id":"el31306188208","type":"ellipse","x":-3.6381570442629396,"y":446.264092339979,"width":6.24,"height":6.24,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["search"],"index":"b4w"}, + {"id":"li119255298416","type":"line","x":1.561842955737049,"y":451.464092339979,"width":2.6,"height":2.6,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["search"],"points":[[0,0],[2.6,2.6]],"index":"b4x"}, + {"id":"el683576232039","type":"ellipse","x":-8.318157044262946,"y":246.584092339979,"width":27.04,"height":27.04,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["search"],"index":"b4y"}, + {"id":"el75085630712","type":"ellipse","x":1.023207069063389,"y":712.88,"width":18.72,"height":18.72,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["message"],"index":"b4{"}, + {"id":"el687117614750","type":"ellipse","x":855.27,"y":343.64,"width":59.52,"height":59.52,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["newres"],"boundElements":[{"id":"ar967582753031","type":"arrow"}],"index":"b4|"}, + {"id":"el430900442039","type":"ellipse","x":1435.27,"y":1313.64,"width":59.52,"height":59.52,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["moved"],"boundElements":[{"id":"ar365433195697","type":"arrow"}],"index":"b4}"}, + {"id":"tx109101110116","type":"text","x":26.0,"y":808.62,"width":64.59310344827585,"height":10,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"@bakery.simplex","fontSize":8,"fontFamily":9,"index":"b4~","strokeWidth":2,"lineHeight":1.25}, + {"id":"i9ADag2YXvSUyLm-vKlDH","type":"rectangle","x":644.952908536373,"y":210.90187429288562,"width":0.25151942394802523,"height":2327.68,"strokeColor":"#1971c2","backgroundColor":"#a5d8ff","fillStyle":"solid","strokeWidth":2,"roughness":1,"index":"b40","roundness":{"type":3},"boundElements":[{"id":"ar625193548486","type":"arrow"},{"id":"ar967582753031","type":"arrow"},{"id":"ar998006064859","type":"arrow"},{"id":"ar996082616004","type":"arrow"},{"id":"ar1094370764209","type":"arrow"},{"id":"ar802878277106","type":"arrow"},{"id":"ar641649257354","type":"arrow"},{"id":"ar428625206945","type":"arrow"},{"id":"ar95247738220","type":"arrow"},{"id":"ar11920695389","type":"arrow"},{"id":"ar174135805467","type":"arrow"},{"id":"ar467310991719","type":"arrow"},{"id":"ar365433195697","type":"arrow"},{"id":"ar26133587117","type":"arrow"},{"id":"ar537817736884","type":"arrow"},{"id":"ar383945949602","type":"arrow"},{"id":"ar534696206681","type":"arrow"},{"id":"ar51174435892","type":"arrow"},{"id":"ar365433195697ch3c","type":"arrow"},{"id":"ar174135805467both3f","type":"arrow"},{"id":"ar537817736884ch4","type":"arrow"}]}, + {"id":"te4b0000000001","type":"text","x":1110.94,"y":2349.47,"width":128.17,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["unconf"],"text":"profile, if you are the owner.","fontSize":9,"fontFamily":9,"index":"b50","strokeWidth":2,"lineHeight":1.25}, + {"id":"relist3ch3c","type":"rectangle","x":1298,"y":1770,"width":271.99,"height":228.8,"index":"b5LV111","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"strokeColor":"#868e96"}, + {"id":"ellist3ch3c","type":"ellipse","x":1302,"y":1777,"width":10,"height":10,"index":"b5LV112","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"strokeColor":"#868e96"}, + {"id":"telist3ch3c","type":"text","x":1315,"y":1776,"width":35.58,"height":12.5,"text":"#bakery","fontSize":10,"fontFamily":9,"index":"b5LV113","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"strokeColor":"#868e96"}, + {"id":"re83588957364ch3c","type":"rectangle","x":1360,"y":1770,"width":209.99,"height":228.8,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"index":"b5LV114","strokeWidth":2}, + {"id":"te45960574281ch3c","type":"text","x":1444.58,"y":1854.36,"width":40.9,"height":13.17,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"Bakery","fontSize":10,"fontFamily":9,"index":"b5LV115","strokeWidth":2,"lineHeight":1.25}, + {"id":"te447455144019ch3c","type":"text","x":1447.51,"y":1870.76,"width":35.03,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"#bakery","fontSize":9,"fontFamily":9,"index":"b5LV116","strokeWidth":2,"lineHeight":1.25}, + {"id":"tesubs3cch3c","type":"text","x":1431.64,"y":1888.85,"width":66.72,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"12 subscribers","fontSize":9,"fontFamily":9,"index":"b5LV117V","strokeWidth":2,"lineHeight":1.25}, + {"id":"te869784825642ch3c","type":"text","x":1431.43,"y":1942.31,"width":67.21,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"Open channel","fontSize":10,"fontFamily":9,"index":"b5LV119","strokeWidth":2,"lineHeight":1.25}, + {"id":"te94030513593ch3c","type":"text","x":1447.05,"y":1968.35,"width":35.95,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"Cancel","fontSize":10,"fontFamily":9,"index":"b5LV11A","strokeWidth":2,"lineHeight":1.25}, + {"id":"re965099215536ch3c","type":"rectangle","x":1535,"y":1751,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"index":"b5LV11B","strokeWidth":2}, + {"id":"te1033495344727ch3c","type":"text","x":1541.95,"y":1753.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"new","fontSize":10,"fontFamily":9,"index":"b5LV11C","strokeWidth":2,"lineHeight":1.25}, + {"id":"te208733714710ch3c","type":"text","x":1360,"y":2006.14,"width":193.52,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"3c\u2032. Leads to a new channel","fontSize":13,"fontFamily":9,"index":"b5LV11D","strokeWidth":2,"lineHeight":1.25}, + {"id":"te377882734292ch3c","type":"text","x":1360,"y":2022.78,"width":216.97,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"The same as 3c when your chat is a channel: the\nname now leads to another channel.","fontSize":11,"fontFamily":9,"index":"b5LV11E","strokeWidth":2,"lineHeight":1.25}, + {"id":"te114834797981ch3c","type":"text","x":1398.84,"y":1722.25,"width":112.53,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"#?bakery.simplex\nleads to a new channel","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar365433195697ch3c","index":"b5LV11F","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar365433195697ch3c","type":"arrow","x":658.7272727272726,"y":1690,"width":806.1677272727273,"height":75,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te114834797981ch3c","type":"text"}],"points":[[0,0],[806.1677272727273,0],[806.1677272727273,75]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"el430900442039ch3c","focus":0,"gap":40,"fixedPoint":[0.499496,-0.665995]},"index":"b5LV11G","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[54.76461409893341,0.635439],"focus":0,"gap":0}}, + {"id":"el430900442039ch3c","type":"ellipse","x":1435.27,"y":1783.64,"width":59.52,"height":59.52,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeWidth":2,"roughness":0,"groupIds":["movedch3c"],"boundElements":[{"id":"ar365433195697ch3c","type":"arrow"}],"index":"b5LV11H"}, + {"id":"relist1both3f","type":"rectangle","x":718,"y":1770,"width":60,"height":72,"index":"b5LV11W","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, + {"id":"ellist1both3f","type":"ellipse","x":722,"y":1777,"width":10,"height":10,"index":"b5LV11X","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, + {"id":"telist1both3f","type":"text","x":735,"y":1776,"width":35.58,"height":12.5,"text":"#bakery","fontSize":10,"fontFamily":9,"index":"b5LV11Y","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, + {"id":"re106891177647both3f","type":"rectangle","x":780,"y":1770,"width":210,"height":72,"strokeColor":"#1e1e1e","fillStyle":"solid","strokeStyle":"dashed","roughness":0,"index":"b5LV11Z","strokeWidth":2,"boundElements":[{"id":"ar174135805467both3f","type":"arrow"}],"groupIds":["bothkinds"]}, + {"id":"te142761910350both3f","type":"text","x":858.36,"y":1792.34,"width":53.28,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"no alert","fontSize":13,"fontFamily":9,"index":"b5LV11a","strokeWidth":2,"lineHeight":1.25,"groupIds":["bothkinds"]}, + {"id":"te426316295647both3f","type":"text","x":795.94,"y":1811.98,"width":178.1,"height":13.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"both chats are in the filtered list","fontSize":11,"fontFamily":9,"index":"b5LV11b","strokeWidth":2,"lineHeight":1.25,"groupIds":["bothkinds"]}, + {"id":"re1034366287668both3f","type":"rectangle","x":924.4,"y":1751,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"index":"b5LV11c","strokeWidth":2}, + {"id":"te776486861809both3f","type":"text","x":933.92,"y":1753.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b5LV11d","strokeWidth":2,"lineHeight":1.25}, + {"id":"te688924689147both3f","type":"text","x":780,"y":1849.34,"width":134.23,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"text":"3f. Both kinds local","fontSize":13,"fontFamily":9,"index":"b5LV11e","strokeWidth":2,"lineHeight":1.25}, + {"id":"te103155488047both3f","type":"text","x":780,"y":1865.98,"width":233.56,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"text":"Search lists both chats. A name in a message has\n@ or #, so it is one of 3a to 3d, or 3c\u2032. The new\nchat sheet shows the channel's alert alone;\n@bakery.simplex reaches the contact.","fontSize":11,"fontFamily":9,"index":"b5LV11f","strokeWidth":2,"lineHeight":1.25}, + {"id":"te25625561850both3f","type":"text","x":835.08,"y":1722.25,"width":80.73,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"bakery.simplex\nboth kinds found","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar174135805467both3f","index":"b5LV11g","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar174135805467both3f","type":"arrow","x":655,"y":1719,"width":229.89999999999998,"height":46,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te25625561850both3f","type":"text"}],"points":[[0,0],[229.89999999999998,0],[229.89999999999998,46]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"index":"b5LV11h","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945588718043346,0.647898],"focus":0,"gap":0},"endBinding":{"elementId":"re106891177647both3f","focus":0,"gap":26,"fixedPoint":[0.5001,-0.361111]}}, + {"id":"ellist1both3f2","type":"ellipse","x":722,"y":1793,"width":10,"height":10,"index":"b5LV11i","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, + {"id":"telist1both3f2","type":"text","x":735,"y":1792,"width":35.58,"height":12.5,"text":"@bakery","fontSize":10,"fontFamily":9,"index":"b5LV11j","strokeWidth":2,"lineHeight":1.25,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, + {"id":"terow2channel","type":"text","x":1070,"y":1770,"width":211.53,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"3a, 3b and 3d read the same for a local\nchannel, reached by #?bakery.simplex.","fontSize":11,"fontFamily":9,"index":"b5LV11jV","strokeWidth":2,"lineHeight":1.25}, + {"id":"re445215284405ch4","type":"rectangle","x":1940,"y":2270,"width":209.99,"height":158.14,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"boundElements":[{"id":"ar537817736884ch4","type":"arrow"}],"index":"b5LV11jVV01","strokeWidth":2}, + {"id":"te83893938407ch4","type":"text","x":2006.11,"y":2286.38,"width":77.77,"height":15.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"Your channel","fontSize":12,"fontFamily":9,"index":"b5LV11jVV02","strokeWidth":2,"lineHeight":1.25}, + {"id":"te549755328637ch4","type":"text","x":1950.0,"y":2309.49,"width":189.99,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"This is your link for channel Alice (#alice)!","fontSize":9,"fontFamily":9,"index":"b5LV11jVV03","strokeWidth":2,"lineHeight":1.25}, + {"id":"te131860867684ch4","type":"text","x":2009.73,"y":2345.57,"width":70.52,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"Open channel","fontSize":10,"fontFamily":9,"index":"b5LV11jVV04","strokeWidth":2,"lineHeight":1.25}, + {"id":"te118607224489ch4","type":"text","x":1985.72,"y":2371.65,"width":118.54,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"Connect to @alice.simplex","fontSize":10,"fontFamily":9,"index":"b5LV11jVV05","strokeWidth":2,"lineHeight":1.25}, + {"id":"te486433536921ch4","type":"text","x":2027.05,"y":2397.69,"width":35.95,"height":12.4,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"Cancel","fontSize":10,"fontFamily":9,"index":"b5LV11jVV06","strokeWidth":2,"lineHeight":1.25}, + {"id":"re833440366279ch4","type":"rectangle","x":2084.4,"y":2251,"width":65.6,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"index":"b5LV11jVV07","strokeWidth":2}, + {"id":"te10203453787ch4","type":"text","x":2093.92,"y":2253.8,"width":46.56,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"changed","fontSize":10,"fontFamily":9,"index":"b5LV11jVV08","strokeWidth":2,"lineHeight":1.25}, + {"id":"te301725264139ch4","type":"text","x":1940,"y":2435.44,"width":181.28,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"4a\u2032. Leads to your channel","fontSize":13,"fontFamily":9,"index":"b5LV11jVV09","strokeWidth":2,"lineHeight":1.25}, + {"id":"te595067966648ch4","type":"text","x":1940,"y":2452.08,"width":233.75,"height":96.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownchannel"],"text":"From a message or the new chat sheet. A name\ntyped without @ or # that also leads to a\ncontact offers it too. From search, your\nchannel is filtered in the list. Also when the\nname has no link of its kind, is not registered,\nor its new link claims another name or can't be\njoined yet.","fontSize":11,"fontFamily":9,"index":"b5LV11jVV10","strokeWidth":2,"lineHeight":1.25}, + {"id":"ar537817736884ch4","type":"arrow","x":655,"y":2148,"width":1389.99,"height":117.0,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te371607245649ch4","type":"text"}],"points":[[0,0],[1389.99,0],[1389.99,117.0]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re445215284405ch4","focus":0,"gap":26,"fixedPoint":[0.5001,-0.141197]},"index":"b5LV11jVV11","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[28.1743526390025,0.832201],"focus":0,"gap":0}}, + {"id":"te371607245649ch4","type":"text","x":1999.03,"y":2221.25,"width":80.74,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"#?alice.simplex\nyours, a channel","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar537817736884ch4","index":"b5LV11jVV12","strokeWidth":2,"lineHeight":1.25} + ], + "appState": {"gridSize": 20, "gridStep": 5, "gridModeEnabled": false, "viewBackgroundColor": "#ffffff"}, + "files": {} +} diff --git a/plans/sketches/README.md b/plans/sketches/README.md new file mode 100644 index 0000000000..c2076f5a9a --- /dev/null +++ b/plans/sketches/README.md @@ -0,0 +1,97 @@ +# Wireframe collaboration without a shared agent + +Two developers work the same UX flow. Each has an LLM agent, and the agents share nothing: +no session, no memory, no canvas. The thing under review is a picture. The fix is to make +the diagram a source file. It lives in the repo, both humans edit it in their IDE, both +agents read and write it directly, and every change is inspected as a rendered image. + +## The loop + +**1. Generate.** Alice prompts her agent to write the flow as an `.excalidraw` file, asking +for the wireframe and for a file that stays comfortable to edit by hand. The second half is +what agents get wrong by default, so it is spelled out below. + +**2. Edit.** Alice opens it in her IDE with the +[Excalidraw Editor plugin](https://plugins.jetbrains.com/plugin/32029-excalidraw-editor) +(id 32029, by Agustin). Same window as the code, same worktree, no export step. + +**3. Diff.** She renders both revisions with +[excalirender](https://github.com/JonRC/excalirender) and flips between them as an animated +GIF at the same pixel alignment, so anything that moved flickers. This is also how she +checks what her agent did when she asked for an edit. + +**4. Review.** She commits to `plans/sketches/` and asks Bob, who edits the diagram in his +own IDE and commits back, or reviews in text on GitHub. His agent reads the same file and +needs nothing from Alice's. + +**5. Iterate.** Alice renders Bob's commit against its parent and goes back to step 1 or 2. + +The repo is the channel, the file is the message, the render is the review surface. + +example PR: https://github.com/simplex-chat/simplex-chat/pull/7525 + +## Conventions + +For the agent. Follow these when generating or editing an `.excalidraw` file here. + +### Keep it editable by hand + +- **Group every panel.** Each screen, alert or card is one group, so dragging moves the box, + its labels and its buttons together. Nest a second level for a row or a band. +- **Bind both ends of every arrow**, through `startBinding` and `endBinding` plus the + arrow's id in each target's `boundElements`. An unbound arrow detaches on the first drag. + Lines are not bindable, so where arrows fan out from a rail, make that rail a very thin + rectangle rather than a line. +- **Elbow arrows** store the attachment as `fixedPoint` on the binding. Dropping an endpoint + onto a target a fraction of a pixel wide resets it to the side midpoint, so set the value + instead of dragging. +- **Labels in containers.** A text with a `containerId` moves and wraps with its box. +- **One multiline text per paragraph.** Lines centred inside a card stay separate elements. +- One flow per file, named `YYYY-MM-DD-topic.excalidraw`, in `plans/sketches/`. + +### Draw primitives, not cosmetics + +Boxes, arrows and text in black, plus one accent colour for whatever is tappable. No fills, +rounded corners, phone bezels, avatars or status bars. Group sections with a filled, +borderless rectangle behind them in a desaturated tint, one per section. Rows share a single +y and a fixed column pitch, and a caption sits 7.4px under its card, left edges aligned. + +### Use fontFamily 9 + +Liberation Sans. Never 2, Helvetica: excalirender does not bundle it, falls back to +monospace, and renders every string at 140% of its stored width, so every card overflows. +Both tools bundle 9, and it is metric-compatible with Helvetica. + +### Normalize before every commit + +The plugin writes the full element schema, pretty-printed, on every save: 14k lines and +~90k tokens for a 354-element sketch. `plans/sketches/normalize-excalidraw.py` drops the +fields Excalidraw re-derives on load and writes one element per line, and +`.git/hooks/pre-commit` runs it over staged files. A changed element then costs one changed +line: a real commit went from 219 insertions and 826 deletions to 28 and 43. + +Never add a field to its strip list without a plugin round trip to prove it. `strokeWidth` +defaults to 2, so dropping a 1 doubles every stroke in the drawing, and `lineHeight` is +recomputed from font metrics when absent. + +### Diff visually, not textually + +``` +git show : > /tmp/old.excalidraw +excalirender /tmp/old.excalidraw -o old.png -s 2 +excalirender -o new.png -s 2 +excalirender diff /tmp/old.excalidraw # element counts and tags +``` + +`excalirender diff` classifies correctly but renders only the changed elements, which on a +sketch where 42 of 354 changed is a near-blank canvas. Build the flip from two full renders +and align them by content bounding box: the tool crops with a 20px margin, so a revision +whose box shifted renders at a different offset and the flip jitters. + +Arrow labels are drawn where they are stored rather than recomputed, so keep them about +16px clear of the line or excalirender strikes through them. + +### Report in the reviewer's terms + +Name panels by the labels a human reads on them. "Panel 4e removed, the price in 2c went +from $20 to $100" is reviewable. "15 elements removed, 27 modified" is only the headline. diff --git a/plans/sketches/normalize-excalidraw.py b/plans/sketches/normalize-excalidraw.py new file mode 100644 index 0000000000..ed3ea87a3c --- /dev/null +++ b/plans/sketches/normalize-excalidraw.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Shrink an .excalidraw file without changing what it renders. + +Excalidraw writes every element with its full schema, pretty-printed, so a +354-element sketch costs 14k lines and ~90k tokens to read, and a one-field +edit shows up as a 27-line diff block. This drops the fields Excalidraw +re-derives on load and puts one element per line, so a changed element is +one changed line. + +Kept on purpose: roughness, fillStyle, fontFamily and strokeColor, which +this drawing sets away from the Excalidraw default; strokeWidth, whose +default is 2, so dropping a 1 doubles the stroke; and lineHeight, which +Excalidraw recomputes from font metrics when it is absent. + +Usage: normalize-excalidraw.py FILE... rewrites each file in place + +Runs from .git/hooks/pre-commit on every staged .excalidraw file. +""" +import json +import sys + +# Rewritten by Excalidraw on every save; carries no meaning for the drawing. +DROP = {"version", "versionNonce", "updated", "seed", "baseline"} + +# Dropped only where the element already holds the default value. +DEFAULT = { + "isDeleted": False, "locked": False, "link": None, "frameId": None, + "angle": 0, "opacity": 100, "strokeStyle": "solid", + "groupIds": [], "boundElements": [], "roundness": None, + "backgroundColor": "transparent", "autoResize": True, + "textAlign": "left", "verticalAlign": "top", "containerId": None, + "startBinding": None, "endBinding": None, "startArrowhead": None, + "endArrowhead": None, "lastCommittedPoint": None, + "startIsSpecial": None, "endIsSpecial": None, +} + + +def lean(element): + return {k: v for k, v in element.items() + if k not in DROP + and not (k in DEFAULT and v == DEFAULT[k]) + and not (k == "originalText" and v == element.get("text"))} + + +def dump(scene): + head = [f"{json.dumps(k)}: {json.dumps(v)}" for k, v in scene.items() + if k not in ("elements", "appState", "files")] + body = ",\n".join(" " + json.dumps(lean(e), separators=(",", ":")) + for e in scene["elements"]) + tail = [f"{json.dumps(k)}: {json.dumps(scene[k])}" + for k in ("appState", "files") if k in scene] + return ("{\n " + ",\n ".join(head) + ',\n "elements": [\n' + body + + "\n ],\n " + ",\n ".join(tail) + "\n}\n") + + +def main(paths): + for path in paths: + with open(path) as f: + before = f.read() + text = dump(json.loads(before)) + with open(path, "w") as f: + f.write(text) + print(f"{path}: {len(before)} -> {len(text)} bytes, " + f"{before.count(chr(10))} -> {text.count(chr(10))} lines") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit(__doc__) + main(sys.argv[1:]) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2d553bdc9b..08b939baeb 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -93,7 +93,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), USDCents, XFTPServer) import Simplex.Messaging.Session (SessionVar) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) @@ -712,8 +712,8 @@ data ChatCommand deriving (Show) data PlanResolveMode - = PRMAllGroups -- resolve all known groups and all unknown chats - | PRMUnknown -- only resolve if chat is unknown (default) + = PRMAllGroups -- resolve names, links of known groups and of unknown chats + | PRMUnknown -- resolve names, and links of unknown chats (default) | PRMNever -- do not resolve links and names, only do local search deriving (Eq, Show) @@ -987,6 +987,7 @@ data ChatEvent | CEvtGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CEvtBusinessLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, fromContact :: Contact} | CEvtContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CEvtNameMoved {user :: User, contactIds :: [ContactId], groupIds :: [GroupId]} | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CEvtContactDeletedByContact {user :: User, contact :: Contact} | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} @@ -1156,11 +1157,26 @@ data ChatDeleteMode data ConnectionPlan = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} - | CPContactAddress {contactAddressPlan :: ContactAddressPlan} - | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan, nameChange :: Maybe NameChange} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan, nameChange :: Maybe NameChange} | CPError {chatError :: ChatError} deriving (Show) +data NameChange + = NCLapsed {nameWarning :: NameWarning} + | NCMoved {knownChat :: AChatInfo} + deriving (Show) + +data NameWarning + = NWExpired {expiredAt :: UTCTime, graceUntil :: Maybe UTCTime} + | NWAvailable {price :: NamePrice} + | NWReservedForCommunity + | NWNotRegistered + deriving (Eq, Show) + +data NamePrice = NamePrice {amount :: USDCents, years :: Int} + deriving (Eq, Show) + data InvitationLinkPlan = ILPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification} | ILPOwnLink @@ -1219,13 +1235,15 @@ connectionPlanProceed = \case ILPOk {} -> True ILPOwnLink -> True _ -> False - CPContactAddress cap -> case cap of + CPContactAddress _ (Just NCLapsed {}) -> False + CPContactAddress cap _ -> case cap of CAPOk {} -> True CAPOwnLink -> True CAPConnectingConfirmReconnect -> True CAPContactViaAddress _ -> True _ -> False - CPGroupLink glp -> case glp of + CPGroupLink _ (Just NCLapsed {}) -> False + CPGroupLink glp _ -> case glp of GLPOk {} -> True GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True @@ -1234,6 +1252,12 @@ connectionPlanProceed = \case _ -> False CPError _ -> True +connectionPlanOwnLink :: ConnectionPlan -> Bool +connectionPlanOwnLink = \case + CPContactAddress CAPOwnLink _ -> True + CPGroupLink GLPOwnLink {} _ -> True + _ -> False + data ForwardConfirmation = FCFilesNotAccepted {fileIds :: [FileTransferId]} | FCFilesInProgress {filesCount :: Int} @@ -1482,6 +1506,7 @@ data ChatError data SimplexDomainError = SDENoValidLink -- the name's record has no usable contact/channel link | SDEUnknownDomain -- the resolved link's profile has no name, or a different name + | SDENameWarning {nameWarning :: NameWarning} deriving (Eq, Show) data BadgeRedeemError @@ -1849,6 +1874,10 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) +$(JQ.deriveJSON defaultJSON ''NamePrice) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NW") ''NameWarning) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SDE") ''SimplexDomainError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BRE") ''BadgeRedeemError) @@ -1865,6 +1894,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "DB") ''DatabaseError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Chat") ''ChatError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NC") ''NameChange) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c37c45ec90..74af268ede 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -120,9 +120,10 @@ import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys ( import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NameRecord (..), NameRegistration (..), NameResponse (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (NAME), MsgFlags (..), NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason (..), NameResponse (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), USDCents (..), UserProtocol, userProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) +import Simplex.Messaging.SystemTime (SystemSeconds, getSystemSeconds, roundedToUTCTime) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth) import Simplex.Messaging.Util @@ -1963,7 +1964,7 @@ processChatCommand cxt nm = \case (_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks}), _) <- getShortLinkConnReq' nm user sLnk groupSLinkData_ <- liftIO $ decodeLinkUserData cData gInfo' <- case groupSLinkData_ of - Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData Nothing + Just sLinkData -> (\(g, _, _) -> g) <$> updateGroupFromLinkData user gInfo sLinkData Nothing _ -> pure gInfo when (memberRole' (membership gInfo) /= GROwner && memberCurrent (membership gInfo)) $ withGroupLock "syncSubscriberRelays" groupId $ @@ -2186,7 +2187,8 @@ processChatCommand cxt nm = \case pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do - let ContactShortLinkData {profile, message, business} = contactSLinkData + let ContactShortLinkData {profile = profile@Profile {contactDomain}, message, business} = contactSLinkData + domain_ = if verifiedDomain == (claimDomain <$> contactDomain) then verifiedDomain else Nothing welcomeSharedMsgId <- forM message $ \_ -> getSharedMsgId case accLink of ACCL SCMContact ccLink @@ -2195,7 +2197,10 @@ processChatCommand cxt nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing verifiedDomain + (gInfo, hostMember_, moved) <- withStore $ \db -> do + (g, hm_) <- createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing domain_ + (g, hm_,) <$> if isJust domain_ then liftIO (setOtherNameChatsMoved db user (GroupChat g Nothing)) else pure ([], []) + nameMovedToView user moved hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -2208,7 +2213,10 @@ processChatCommand cxt nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq sLnk) -> do - ct <- withStore $ \db -> createPreparedContact db cxt user (linkPresHeader <$> sLnk) profile accLink welcomeSharedMsgId (True <$ verifiedDomain) + (ct, moved) <- withStore $ \db -> do + ct <- createPreparedContact db cxt user (linkPresHeader <$> sLnk) profile accLink welcomeSharedMsgId (True <$ domain_) + (ct,) <$> if isJust domain_ then liftIO (setOtherNameChatsMoved db user (DirectChat ct)) else pure ([], []) + nameMovedToView user moved void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing @@ -2221,9 +2229,10 @@ processChatCommand cxt nm = \case _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat APIPrepareGroup userId ccLink direct verifiedDomain groupSLinkData -> withUserId userId $ \user -> do - let GroupShortLinkData {groupProfile = GroupProfile {description}} = groupSLinkData + let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData + domain_ = if verifiedDomain == groupClaim gp then verifiedDomain else Nothing welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId verifiedDomain + (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId domain_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2238,7 +2247,10 @@ processChatCommand cxt nm = \case when (isNothing preparedContact) $ throwCmdError "contact doesn't have link to connect" when (isJust $ contactConn ct) $ throwCmdError "contact already has connection" newUser <- privateGetUser newUserId - ct' <- withFastStore $ \db -> updatePreparedContactUser db cxt user ct newUser + (ct', moved) <- withFastStore $ \db -> do + ct'@Contact {profile = LocalProfile {contactDomainVerified}} <- updatePreparedContactUser db cxt user ct newUser + (ct',) <$> if contactDomainVerified == Just DVVerified then liftIO (setOtherNameChatsMoved db newUser (DirectChat ct')) else pure ([], []) + nameMovedToView newUser moved -- create changed feature items (new user may have different preferences) lift $ createContactChangedFeatureItems user ct ct' pure $ CRContactUserChanged user ct newUser ct' @@ -2253,7 +2265,10 @@ processChatCommand cxt nm = \case when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" pure $ Just hostMember newUser <- privateGetUser newUserId - gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db cxt user gInfo hostMember_ newUser + (gInfo', moved) <- withFastStore $ \db -> do + gInfo'@GroupInfo {groupDomainVerified} <- updatePreparedGroupUser db cxt user gInfo hostMember_ newUser + (gInfo',) <$> if groupDomainVerified == Just DVVerified then liftIO (setOtherNameChatsMoved db newUser (GroupChat gInfo' Nothing)) else pure ([], []) + nameMovedToView newUser moved pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId @@ -2417,7 +2432,12 @@ processChatCommand cxt nm = \case let connLink_ = preparedContact >>= \PreparedContact {connLinkToConnect = ACCL m (CCLink _ sLnk_)} -> ACSL m <$> sLnk_ domain <- maybe (throwCmdError "contact has no name to verify") pure contactDomain (verified, reason) <- verifyEntityDomain user nm NTContact domain connLink_ - ct' <- maybe (pure ct) (\v -> withFastStore' $ \db -> setContactDomainVerified db user ct v) verified + (ct', moved) <- case verified of + Just v -> withFastStore' $ \db -> do + ct' <- setContactDomainVerified db user ct v + (ct',) <$> if v then setOtherNameChatsMoved db user (DirectChat ct') else pure ([], []) + Nothing -> pure (ct, ([], [])) + nameMovedToView user moved pure $ CRContactDomainVerified user ct' reason APIVerifyGroupDomain groupId -> withUser $ \user -> do g@GroupInfo {groupProfile = GroupProfile {publicGroup}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -2431,7 +2451,10 @@ processChatCommand cxt nm = \case | otherwise -> pure (False, Just "the name does not resolve to the link in the group profile") Left (ChatErrorAgent {agentError = SMP _ (NAME SMP.NOT_FOUND)}) -> pure (False, Just "the name is not registered") Left e -> throwError e - g' <- withFastStore' $ \db -> setGroupDomainVerified db user g verified + (g', moved) <- withFastStore' $ \db -> do + g' <- setGroupDomainVerified db user g verified + (g',) <$> if verified then setOtherNameChatsMoved db user (GroupChat g' Nothing) else pure ([], []) + nameMovedToView user moved pure $ CRGroupDomainVerified user g' reason APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do ct@Contact {profile = LocalProfile {contactLink}, groupDirectInv} <- withFastStore $ \db -> getContact db cxt user contactId @@ -2449,7 +2472,7 @@ processChatCommand cxt nm = \case toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do - plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing)) + plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing) Nothing) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) Nothing Nothing plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing @@ -4169,9 +4192,14 @@ processChatCommand cxt nm = \case checkGroupProfileSize p' when (useRelays' gInfo && isJust (ma' >>= review)) $ throwCmdError "Admission review is not supported in channels" -- updateGroupProfile clears domain verification; re-set it when the caller already re-resolved the name - gInfo' <- withStore $ \db -> do + (gInfo', moved) <- withStore $ \db -> do g <- updateGroupProfile db user gInfo p' - if domainVerified then liftIO $ setGroupDomainVerified db user g True else pure g + if domainVerified + then liftIO $ do + g' <- setGroupDomainVerified db user g True + (g',) <$> setOtherNameChatsMoved db user (GroupChat g' Nothing) + else pure (g, ([], [])) + nameMovedToView user moved msg <- case businessChat of Just BusinessChatInfo {businessId} -> do ms <- withStore' $ \db -> getGroupMembers db cxt user gInfo' @@ -4502,7 +4530,7 @@ processChatCommand cxt nm = \case -- local search only: look up #d then @d in the store, without online name resolution | resolveMode == PRMNever -> connectPlanNoName $ ChatError CENotResolvedLocally | otherwise -> - tryAllErrors (resolveNameRecord user nm d) >>= \case + tryAllErrors (resolveConnectableName user nm d) >>= \case Right nr | isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) -> (addOther nr <$> connectPlanName NTPublicGroup (Right nr)) `catchAllErrors` \e -> @@ -4533,10 +4561,12 @@ processChatCommand cxt nm = \case (\(l, p) -> (l, simplexName_, Nothing, p)) <$> case ctType of CCTContact -> knownLinkPlans >>= \case - Just r -> pure r + Just r -> knownNamePlan r contactLinkPlan Nothing -> do when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally - l' <- resolveSLink + resolveSLink >>= contactLinkPlan Nothing + where + contactLinkPlan knownChat_ l' = do (FixedLinkData {rootKey}, cData, cReq) <- getShortLinkConnReq nm user l' let presHeader = linkPresHeader l' contactSLinkData_ <- mapM (linkDataBadge presHeader) =<< liftIO (decodeLinkUserData cData) @@ -4545,33 +4575,39 @@ processChatCommand cxt nm = \case planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing refreshContact ct' = case (planDomain, linkProfile_) of (Just _, Just p) -> updateContactFromLinkData user ct' presHeader p - _ -> pure ct' + _ -> pure (ct', ([], [])) + knownMoved moved = NCMoved . movedChat moved <$> knownChat_ forM_ planDomain $ \nameDomain -> unless (linkDomain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case Just ct' | not (contactDeleted ct') -> do - ct'' <- refreshContact ct' - pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'')) + (ct'', moved) <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'') (knownMoved moved)) _ -> do let ContactLinkData _ UserContactData {owners} = cData ov = verifyLinkOwner rootKey owners l' sig_ - plan <- contactRequestPlan user cReq contactSLinkData_ ov + plan <- setNameChange (NCMoved <$> knownChat_) <$> contactRequestPlan user cReq contactSLinkData_ ov case plan of - 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'')) + CPContactAddress (CAPKnown ct') _ -> do + (ct'', moved) <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPKnown ct'') (knownMoved moved)) + CPContactAddress (CAPContactViaAddress ct') _ -> do + (ct'', moved) <- refreshContact ct' + pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct'') (knownMoved moved)) + CPGroupLink (GLPKnown g@GroupInfo {businessChat = Just _} u o os) _ | Just d <- planDomain -> do + (g', moved) <- withFastStore' $ \db -> do + g' <- setPreparedGroupDomain db user g d + (g',) <$> setOtherNameChatsMoved db user (GroupChat g' Nothing) + nameMovedToView user moved + pure (con l' cReq, CPGroupLink (GLPKnown g' u o os) (knownMoved moved)) _ -> pure (con l' cReq, plan) - where knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan)) knownLinkPlans = withFastStore $ \db -> liftIO (getUserContactLinkViaTarget db user nl') >>= \case - Just UserContactLink {connLinkContact} -> pure $ Just (ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink) + Just UserContactLink {connLinkContact} -> pure $ Just (ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink Nothing) Nothing -> getContactToConnect db cxt user nl' >>= \case - Just (ccl, ct') -> pure $ if contactDeleted ct' then Nothing else Just (ACCL SCMContact ccl, CPContactAddress (CAPKnown ct')) + Just (ccl, ct') -> pure $ if contactDeleted ct' then Nothing else Just (ACCL SCMContact ccl, CPContactAddress (CAPKnown ct') Nothing) Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' CCTGroup -> groupShortLinkPlan CCTChannel -> groupShortLinkPlan @@ -4588,21 +4624,26 @@ processChatCommand cxt nm = \case CTLink l' -> pure l' CTName n -> serverShortLink <$> resolveNameLink n con l' cReq = ACCL SCMContact $ CCLink cReq (Just l') - gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef []))) + gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])) Nothing) groupShortLinkPlan :: CM (ACreatedConnLink, ConnectionPlan) groupShortLinkPlan = knownLinkPlans >>= \case - Just (_, CPGroupLink (GLPKnown g _ _ _)) - | resolveMode == PRMAllGroups -> resolveKnownGroup g - Just r -> pure r + Just (_, CPGroupLink (GLPKnown g _ _ _) _) + | resolveMode == PRMAllGroups, isNothing simplexName_ -> resolveKnownGroup g + Just r -> knownNamePlan r groupLinkPlan Nothing -> do when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally - l' <- resolveSLink + resolveSLink >>= groupLinkPlan Nothing + where + groupLinkPlan knownChat_ l' = do (fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays}), cReq) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData - if - | not direct && unsupportedGroupType groupSLinkData_ -> pure (con l' cReq, CPGroupLink (GLPUpdateRequired groupSLinkData_)) - | not direct && null relays -> pure (con l' cReq, CPGroupLink (GLPNoRelays groupSLinkData_)) + let planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing + nc_ = NCMoved <$> knownChat_ + knownMoved moved = NCMoved . movedChat moved <$> knownChat_ + plan <- if + | not direct && unsupportedGroupType groupSLinkData_ -> pure $ CPGroupLink (GLPUpdateRequired groupSLinkData_) nc_ + | not direct && null relays -> pure $ CPGroupLink (GLPNoRelays groupSLinkData_) nc_ | otherwise -> do let FixedLinkData {linkEntityId, rootKey} = fd linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, publicGroupId = B64UrlByteString <$> linkEntityId} @@ -4614,32 +4655,30 @@ processChatCommand cxt nm = \case _ -> throwChatError CEInvalidConnReq let ov = verifyLinkOwner rootKey owners l' sig_ glOwners = map (\OwnerAuth {ownerId, ownerKey} -> GroupLinkOwner {memberId = MemberId ownerId, memberKey = ownerKey}) owners - planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing - plan0 <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov glOwners + plan0 <- setNameChange nc_ <$> groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov glOwners -- a joined channel is found by link but not by name (its domain is not verified locally, -- e.g. an un-upgraded relay dropped the claim); refresh its profile from the fresh link -- data and mark it verified, so the check below passes and future by-name lookups match - plan <- case (planDomain, plan0, groupSLinkData_) of - (Just nameDomain, CPGroupLink (GLPKnown g u o os), Just sLinkData) -> - (\(g', _) -> CPGroupLink (GLPKnown g' u o os)) <$> updateGroupFromLinkData user g sLinkData (Just nameDomain) + case (planDomain, plan0, groupSLinkData_) of + (Just nameDomain, CPGroupLink (GLPKnown g u o os) _, Just sLinkData) -> + (\(g', _, moved) -> CPGroupLink (GLPKnown g' u o os) (knownMoved moved)) <$> updateGroupFromLinkData user g sLinkData (Just nameDomain) _ -> pure plan0 - forM_ planDomain $ \nameDomain -> - let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of - CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _) -> Just groupProfile - CPGroupLink (GLPKnown GroupInfo {groupProfile} _ _ _) -> Just groupProfile - CPGroupLink (GLPOwnLink GroupInfo {groupProfile}) -> Just groupProfile - CPGroupLink (GLPConnectingProhibit (Just GroupInfo {groupProfile})) -> Just groupProfile - _ -> (\GroupShortLinkData {groupProfile} -> groupProfile) <$> groupSLinkData_ - in unless (domain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain - pure (con l' cReq, plan) - where + forM_ planDomain $ \nameDomain -> + let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of + CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _) _ -> Just groupProfile + CPGroupLink (GLPKnown GroupInfo {groupProfile} _ _ _) _ -> Just groupProfile + CPGroupLink (GLPOwnLink GroupInfo {groupProfile}) _ -> Just groupProfile + CPGroupLink (GLPConnectingProhibit (Just GroupInfo {groupProfile})) _ -> Just groupProfile + _ -> (\GroupShortLinkData {groupProfile} -> groupProfile) <$> groupSLinkData_ + in unless (domain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain + pure (con l' cReq, plan) unsupportedGroupType = \case Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel _ -> False knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan)) knownLinkPlans = withFastStore $ \db -> liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case - Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g)) + Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g) Nothing) Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' resolveKnownGroup g = do l' <- resolveSLink @@ -4647,14 +4686,56 @@ processChatCommand cxt nm = \case groupSLinkData_ <- liftIO $ decodeLinkUserData cData let ov = verifyLinkOwner rk owners l' sig_ glOwners = map (\OwnerAuth {ownerId, ownerKey} -> GroupLinkOwner {memberId = MemberId ownerId, memberKey = ownerKey}) owners - (g', updated) <- case groupSLinkData_ of + (g', updated, _) <- case groupSLinkData_ of Just sLinkData -> updateGroupFromLinkData user g sLinkData Nothing - _ -> pure (g, False) - pure (con l' cReq, CPGroupLink (GLPKnown g' updated ov (ListDef glOwners))) + _ -> pure (g, False, ([], [])) + pure (con l' cReq, CPGroupLink (GLPKnown g' updated ov (ListDef glOwners)) Nothing) + movedChat :: ([ContactId], [GroupId]) -> AChatInfo -> AChatInfo + movedChat (ctIds, gIds) (AChatInfo t c) = AChatInfo t $ case c of + DirectChat ct'@Contact {contactId, profile = p} | contactId `elem` ctIds -> DirectChat ct' {profile = p {contactDomainVerified = Just DVMoved}} + GroupChat g@GroupInfo {groupId} s | groupId `elem` gIds -> GroupChat g {groupDomainVerified = Just DVMoved} s + _ -> c + knownNamePlan :: (ACreatedConnLink, ConnectionPlan) -> (Maybe AChatInfo -> ShortLinkContact -> CM (ACreatedConnLink, ConnectionPlan)) -> CM (ACreatedConnLink, ConnectionPlan) + knownNamePlan r@(l, plan) linkPlan + | resolveMode == PRMNever || isNothing simplexName_ = pure r + | otherwise = + tryAllErrors resolveSLink >>= \case + Right l' + | ACCL SCMContact (CCLink _ (Just sl)) <- l, sameShortLinkContact sl l' -> pure r + | otherwise -> (knownChatLink <$> linkPlan knownChat_ l') `catchAllErrors` knownPlanOnError + Left (ChatError (CESimplexDomainNotReady _ (SDENameWarning w))) | w /= NWNotRegistered -> pure (l, setNameChange (Just $ NCLapsed w) plan) + Left e -> knownPlanOnError e + where + knownPlanOnError = \case + e@ChatErrorAgent {agentError} | temporaryOrHostError agentError -> throwError e + e@ChatErrorStore {} -> throwError e + e -> r <$ logWarn ("known chat plan used, name lookup error: " <> tshow e) + knownChat_ = planChat plan + knownChatLink r'@(_, p) = if isKnownChat p then (l, setNameChange Nothing p) else r' + isKnownChat p = isJust knownChat_ && refOf (planChat p) == refOf knownChat_ + refOf = (>>= \(AChatInfo _ c) -> chatInfoToRef c) + setNameChange :: Maybe NameChange -> ConnectionPlan -> ConnectionPlan + setNameChange nc_ = \case + CPContactAddress cap _ -> CPContactAddress cap nc_ + CPGroupLink glp _ -> CPGroupLink glp nc_ + p -> p + planChat :: ConnectionPlan -> Maybe AChatInfo + planChat = \case + CPContactAddress cap _ -> AChatInfo SCTDirect . DirectChat <$> case cap of + CAPKnown ct' -> Just ct' + CAPContactViaAddress ct' -> Just ct' + CAPConnectingProhibit ct' -> Just ct' + _ -> Nothing + CPGroupLink glp _ -> (\g -> AChatInfo SCTGroup $ GroupChat g Nothing) <$> case glp of + GLPKnown g _ _ _ -> Just g + GLPOwnLink g -> Just g + GLPConnectingProhibit g_ -> g_ + _ -> Nothing + _ -> Nothing -- resolve a name to its first contact/channel short link resolveNameLink :: SimplexNameInfo -> CM (ConnShortLink 'CMContact) resolveNameLink SimplexNameInfo {nameType, nameDomain} = do - NameRecord {nrSimplexContact, nrSimplexChannel} <- maybe (resolveNameRecord user nm nameDomain) (ExceptT . pure) nameRec + NameRecord {nrSimplexContact, nrSimplexChannel} <- maybe (resolveConnectableName user nm nameDomain) (ExceptT . pure) nameRec let (candidates, ctType') = case nameType of NTContact -> (nrSimplexContact, CCTContact) NTPublicGroup -> (nrSimplexChannel, CCTChannel) @@ -4664,10 +4745,10 @@ processChatCommand cxt nm = \case | connectionPlanProceed plan = do case plan of CPError e -> eToView e; _ -> pure () case plan of - CPContactAddress (CAPContactViaAddress Contact {contactId}) -> + CPContactAddress (CAPContactViaAddress Contact {contactId}) _ -> processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId - CPContactAddress (CAPOk (Just sld) _) | isJust vName -> connectContactViaName sld - CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _) + CPContactAddress (CAPOk (Just sld) _) _ | isJust vName -> connectContactViaName sld + CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _) _ | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan @@ -4726,7 +4807,7 @@ processChatCommand cxt nm = \case contactRequestPlan user cReq cld ov = do let cReqSchemas = contactCReqSchemas cReq cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas - plan p = pure $ CPContactAddress p + plan p = pure $ CPContactAddress p Nothing withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case Just _ -> plan $ CAPOwnLink Nothing -> @@ -4749,7 +4830,7 @@ processChatCommand cxt nm = \case groupJoinRequestPlan user cReq linkInfo gld ov glOwners = do let cReqSchemas = contactCReqSchemas cReq cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas - plan p = pure $ CPGroupLink p + plan p = pure $ CPGroupLink p Nothing withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case Just g -> plan $ GLPOwnLink g Nothing -> do @@ -4772,7 +4853,7 @@ processChatCommand cxt nm = \case | memberActive membership = plan $ GLPKnown gInfo False ov (ListDef glOwners) | otherwise = plan $ GLPOk linkInfo gld ov where - plan p = pure $ CPGroupLink p + plan p = pure $ CPGroupLink p Nothing contactCReqSchemas :: ConnReqContact -> (ConnReqContact, ConnReqContact) contactCReqSchemas (CRContactUri crData e2e) = ( CRContactUri crData {crScheme = SSSimplex} e2e, @@ -5154,7 +5235,11 @@ processChatCommand cxt nm = \case useRelays = not direct subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ verifiedDomain + (gInfo, hostMember_, moved) <- withStore $ \db -> do + (g, hm_) <- createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_ verifiedDomain + (g, hm_,) <$> if isJust verifiedDomain then liftIO (setOtherNameChatsMoved db user (GroupChat g Nothing)) else pure ([], []) + nameMovedToView user moved + pure (gInfo, hostMember_) getSharedMsgId :: CM SharedMsgId getSharedMsgId = do @@ -5179,6 +5264,27 @@ resolveNameRecord user nm domain = do NRRegistered {nameRecord} -> pure nameRecord _ -> throwError $ chatErrorAgent $ SMP "" (NAME SMP.NOT_FOUND) +resolveConnectableName :: User -> NetworkRequestMode -> SimplexDomain -> CM NameRecord +resolveConnectableName user nm domain = do + NameResponse {registration} <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) domain + now <- liftIO getSystemSeconds + either (throwChatError . CESimplexDomainNotReady domain . SDENameWarning) pure $ nameRecordOrWarning now domain registration + +nameRecordOrWarning :: SystemSeconds -> SimplexDomain -> NameRegistration -> Either NameWarning NameRecord +nameRecordOrWarning now SimplexDomain {domain} = \case + NRRegistered {expires = Just expires, graceUntil} + | expires < now -> Left $ NWExpired (roundedToUTCTime expires) (roundedToUTCTime <$> mfilter (now <=) graceUntil) + NRRegistered {nameRecord} -> Right nameRecord + NRAvailable {pricing = NamePricing {registrationPrices, basePrice, minLabelLength}} + | labelLength >= minLabelLength -> + let USDCents perYear = M.findWithDefault basePrice labelLength registrationPrices + years = 2 + in Left $ NWAvailable NamePrice {amount = USDCents (perYear * fromIntegral years), years} + NRReserved {reservedReason = NRRCommunity} -> Left NWReservedForCommunity + _ -> Left NWNotRegistered + where + labelLength = T.length domain + verifyEntityDomain :: User -> NetworkRequestMode -> SimplexNameType -> SimplexDomainClaim -> Maybe AConnShortLink -> CM (Maybe Bool, Maybe Text) verifyEntityDomain user nm nameType SimplexDomainClaim {domain = StrJSON domain, proof = proof_} connLink_ = case (proof_, connLink_) of (Nothing, _) -> pure (Nothing, Just "no name proof to verify") diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6ea428ac1f..85e4284064 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1631,40 +1631,68 @@ updatePublicGroupData user gInfo gks | otherwise = pure gInfo -- must not resolve names here: a background link-data refresh would leak channel membership to the resolver -updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> Maybe SimplexDomain -> CM (GroupInfo, Bool) +updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> Maybe SimplexDomain -> CM (GroupInfo, Bool, ([ContactId], [GroupId])) updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData} resolvedDomain_ | profileChanged || countChanged || verifyResolved = do cxt <- chatStoreCxt - withStore $ \db -> do + (gInfo', moved) <- withStore $ \db -> do g <- if profileChanged then updateGroupProfile db user gInfo groupProfile else pure gInfo g' <- case publicGroupData of Just PublicGroupData {publicMemberCount} | countChanged -> setPublicMemberCount db cxt user g publicMemberCount _ -> pure g - g'' <- if verifyResolved then liftIO $ setGroupDomainVerified db user g' True else pure g' - pure (g'', profileChanged) - | otherwise = pure (gInfo, False) + if verifyResolved + then liftIO $ do + g'' <- setGroupDomainVerified db user g' True + (g'',) <$> setOtherNameChatsMoved db user (GroupChat g'' Nothing) + else pure (g', ([], [])) + nameMovedToView user moved + pure (gInfo', profileChanged, moved) + | otherwise = pure (gInfo, False, ([], [])) where profileChanged = p /= groupProfile countChanged = case publicGroupData of Just PublicGroupData {publicMemberCount} -> Just publicMemberCount /= localCount _ -> False - groupClaim GroupProfile {publicGroup} = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim) newClaim = groupClaim groupProfile verifyResolved = isJust resolvedDomain_ && resolvedDomain_ == newClaim -updateContactFromLinkData :: User -> Contact -> ProofPresHeader -> Profile -> CM Contact +groupClaim :: GroupProfile -> Maybe SimplexDomain +groupClaim GroupProfile {publicGroup} = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim) + +updateContactFromLinkData :: User -> Contact -> ProofPresHeader -> Profile -> CM (Contact, ([ContactId], [GroupId])) updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} presHeader linkProfile@Profile {contactDomain = newClaim} | profileChanged || verifyChanged = do cxt <- chatStoreCxt - withFastStore $ \db -> do + r@(_, moved) <- withFastStore $ \db -> do ct' <- updateContactProfile db cxt user ct (Just presHeader) linkProfile - if verifyChanged then liftIO $ setContactDomainVerified db user ct' True else pure ct' - | otherwise = pure ct + if verifyChanged + then liftIO $ do + ct'' <- setContactDomainVerified db user ct' True + (ct'',) <$> setOtherNameChatsMoved db user (DirectChat ct'') + else pure (ct', ([], [])) + nameMovedToView user moved + pure r + | otherwise = pure (ct, ([], [])) where profileChanged = fromLocalProfile profile /= linkProfile claimChanged = (claimDomain <$> prevClaim) /= (claimDomain <$> newClaim) - verifyChanged = contactDomainVerified /= Just True || claimChanged + verifyChanged = contactDomainVerified /= Just DVVerified || claimChanged + +setOtherNameChatsMoved :: DB.Connection -> User -> ChatInfo c -> IO ([ContactId], [GroupId]) +setOtherNameChatsMoved db user = \case + DirectChat Contact {contactId, profile = LocalProfile {contactDomain = Just d}} -> setMoved (SimplexNameInfo NTContact $ claimDomain d) (Just contactId) Nothing + GroupChat GroupInfo {groupId, businessChat = Just BusinessChatInfo {businessDomain = Just d}} _ -> setMoved (SimplexNameInfo NTContact $ claimDomain d) Nothing (Just groupId) + GroupChat GroupInfo {groupId, businessChat = Nothing, groupProfile} _ | Just d <- groupClaim groupProfile -> setMoved (SimplexNameInfo NTPublicGroup d) Nothing (Just groupId) + _ -> pure ([], []) + where + setMoved ni@SimplexNameInfo {nameType, nameDomain} ctId_ gId_ = + (,) + <$> (if nameType == NTContact then setNameContactsMoved db user nameDomain ctId_ else pure []) + <*> setNameGroupsMoved db user ni gId_ + +nameMovedToView :: User -> ([ContactId], [GroupId]) -> CM () +nameMovedToView user (ctIds, gIds) = unless (null ctIds && null gIds) $ toView $ CEvtNameMoved user ctIds gIds -- TODO [relays] owner: set owners on updating link data (multi-owner) groupLinkData :: GroupInfoKeys -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 462b3629f1..4d24c41353 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -52,6 +52,7 @@ module Simplex.Chat.Store.Direct getContactIdByName, updateContactProfile, setContactDomainVerified, + setNameContactsMoved, updateContactUserPreferences, updateContactAlias, updateContactConnectionAlias, @@ -115,7 +116,7 @@ import Simplex.Chat.Names (SimplexDomainClaim (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), SConnectionMode (..), SimplexNameInfo (..), UserId) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), SConnectionMode (..), SimplexDomain, SimplexNameInfo (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -599,7 +600,22 @@ setContactDomainVerified db User {userId} ct@Contact {contactId, profile = p} ve WHERE contact_profile_id IN (SELECT contact_profile_id FROM contacts WHERE user_id = ? AND contact_id = ?) |] (BI verified, userId, contactId) - pure (ct {profile = p {contactDomainVerified = Just verified}} :: Contact) + pure (ct {profile = p {contactDomainVerified = Just $ if verified then DVVerified else DVFailed}} :: Contact) + +setNameContactsMoved :: DB.Connection -> User -> SimplexDomain -> Maybe ContactId -> IO [ContactId] +setNameContactsMoved db User {userId} domain exceptContactId_ = do + cts <- + DB.query + db + [sql| + SELECT ct.contact_id, ct.contact_profile_id FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + WHERE ct.user_id = ? AND ct.contact_id IS DISTINCT FROM ? AND cp.contact_domain = ? AND cp.contact_domain_verified = 1 + |] + (userId, exceptContactId_, domain) + forM_ cts $ \(_ :: ContactId, profileId :: ProfileId) -> + DB.execute db "UPDATE contact_profiles SET contact_domain_verified = 2 WHERE contact_profile_id = ?" (Only profileId) + pure $ map fst cts updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e9d99fbe89..1e6e7a9c47 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -47,6 +47,8 @@ module Simplex.Chat.Store.Groups getGroupInfoByGroupLinkHash, updateGroupProfile, setGroupDomainVerified, + setNameGroupsMoved, + setPreparedGroupDomain, updateGroupPreferences, updateGroupProfileFromMember, getGroupIdByName, @@ -234,7 +236,7 @@ import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Badges (BadgeProof, BadgeRow, ProofPresHeader, acceptedBadge, badgeProofToRow, badgeToRow, sameBadgeProof, verifyBadge) -import Simplex.Chat.Names (SimplexDomainClaim (..)) +import Simplex.Chat.Names (SimplexDomainClaim (..), mkDomainClaim) import Simplex.Chat.Messages import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) @@ -2787,12 +2789,30 @@ setGroupDomainVerified db User {userId} g@GroupInfo {groupId} verified = do db "UPDATE groups SET group_domain_verified = ? WHERE user_id = ? AND group_id = ?" (BI verified, userId, groupId) - pure g {groupDomainVerified = Just verified} + pure g {groupDomainVerified = Just $ if verified then DVVerified else DVFailed} + +setNameGroupsMoved :: DB.Connection -> User -> SimplexNameInfo -> Maybe GroupId -> IO [GroupId] +setNameGroupsMoved db User {userId} SimplexNameInfo {nameType, nameDomain} exceptGroupId_ = + map fromOnly <$> DB.query + db + ( [sql| + UPDATE groups SET group_domain_verified = 2 + WHERE user_id = ? AND group_domain_verified = 1 AND group_id IS DISTINCT FROM ? + AND group_profile_id IN (SELECT group_profile_id FROM group_profiles WHERE user_id = ? AND group_domain = ?) + |] + <> businessCond + <> " RETURNING group_id" + ) + (userId, exceptGroupId_, userId, nameDomain) + where + businessCond = case nameType of + NTContact -> " AND business_chat IS NOT NULL" + NTPublicGroup -> " AND business_chat IS NULL" -- A business group has no publicGroup claim, so the domain it was connected by (from its address) is written -- directly to group_domain and marked verified, so it is found by the local name search (getGroupToConnect). setPreparedGroupDomain :: DB.Connection -> User -> GroupInfo -> SimplexDomain -> IO GroupInfo -setPreparedGroupDomain db user@User {userId} g@GroupInfo {groupId} domain = do +setPreparedGroupDomain db user@User {userId} g@GroupInfo {groupId, businessChat} domain = do DB.execute db [sql| @@ -2800,7 +2820,7 @@ setPreparedGroupDomain db user@User {userId} g@GroupInfo {groupId} domain = do WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ?) |] (domain, userId, groupId) - setGroupDomainVerified db user g True + setGroupDomainVerified db user g {businessChat = (\bc -> bc {businessDomain = Just $ mkDomainClaim domain}) <$> businessChat} True updateGroupPreferences :: DB.Connection -> User -> GroupInfo -> GroupPreferences -> IO GroupInfo updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} ps = do @@ -2887,7 +2907,7 @@ getGroupInfoViaUserTarget db cxt user@User {userId} target = fmap eitherToMaybe FROM user_contact_links ucl JOIN groups g ON g.group_id = ucl.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE ucl.user_id = ? AND gp.group_domain = ? + WHERE ucl.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified IS DISTINCT FROM 2 |] (userId, nameDomain ni) toConnReqGroupId = \case diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 433c2ec43f..a7dab6fa7e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -484,7 +484,7 @@ Query: FROM user_contact_links ucl JOIN groups g ON g.group_id = ucl.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE ucl.user_id = ? AND gp.group_domain = ? + WHERE ucl.user_id = ? AND gp.group_domain = ? AND g.group_domain_verified IS DISTINCT FROM 2 Plan: SEARCH ucl USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) @@ -3849,6 +3849,15 @@ Plan: SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT ct.contact_id, ct.contact_profile_id FROM contacts ct + JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id + WHERE ct.user_id = ? AND ct.contact_id IS DISTINCT FROM ? AND cp.contact_domain = ? AND cp.contact_domain_verified = 1 + +Plan: +SEARCH ct USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT d.file_descr_id, d.file_descr_text, d.file_descr_part_no, d.file_descr_complete FROM xftp_file_descriptions d @@ -4434,6 +4443,26 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups SET group_domain_verified = 2 + WHERE user_id = ? AND group_domain_verified = 1 AND group_id IS DISTINCT FROM ? + AND group_profile_id IN (SELECT group_profile_id FROM group_profiles WHERE user_id = ? AND group_domain = ?) + AND business_chat IS NOT NULL RETURNING group_id +Plan: +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=? AND group_profile_id=?) +LIST SUBQUERY 1 +SEARCH group_profiles USING INDEX idx_group_profiles_user_id (user_id=?) + +Query: + UPDATE groups SET group_domain_verified = 2 + WHERE user_id = ? AND group_domain_verified = 1 AND group_id IS DISTINCT FROM ? + AND group_profile_id IN (SELECT group_profile_id FROM group_profiles WHERE user_id = ? AND group_domain = ?) + AND business_chat IS NULL RETURNING group_id +Plan: +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=? AND group_profile_id=?) +LIST SUBQUERY 1 +SEARCH group_profiles USING INDEX idx_group_profiles_user_id (user_id=?) + Query: WITH user_groups AS MATERIALIZED ( SELECT g.group_id @@ -6637,6 +6666,13 @@ Query: Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) +Query: + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND group_id IS NULL AND short_link_contact IS NOT NULL +Plan: +SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) + Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links @@ -8042,6 +8078,10 @@ Query: UPDATE contact_profiles SET contact_domain = ?, updated_at = ? WHERE user Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET contact_domain_verified = 2 WHERE contact_profile_id = ? +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE contact_profiles SET contact_domain_verified = NULL WHERE user_id = ? AND contact_profile_id = ? Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 75a49090ed..10a1c28396 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -494,7 +494,7 @@ type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe type ContactRow = Only ContactId :. ContactRow' -type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe BoolInt) +type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe DomainVerification) toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) = @@ -512,8 +512,8 @@ toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName rowToContactDomain :: ContactDomainRow -> Maybe SimplexDomainClaim rowToContactDomain (domain_, domainProof_, _) = (`SimplexDomainClaim` domainProof_) . StrJSON <$> domain_ -rowToDomainVerified :: ContactDomainRow -> Maybe Bool -rowToDomainVerified (_, _, domainVerification_) = unBI <$> domainVerification_ +rowToDomainVerified :: ContactDomainRow -> Maybe DomainVerification +rowToDomainVerified (_, _, domainVerification_) = domainVerification_ contactDomainToRow :: Maybe SimplexDomainClaim -> (Maybe SimplexDomain, Maybe SimplexDomainProof) contactDomainToRow d = (claimDomain <$> d, proof =<< d) @@ -696,7 +696,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe DomainVerification) :. GroupKeysRow :. GroupMemberRow type PublicGroupAccessRow = (Maybe Text, Maybe SimplexDomain, Maybe BoolInt, Maybe BoolInt, Maybe SimplexDomainProof) @@ -715,7 +715,7 @@ toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayN businessChat = toBusinessChatInfo (toPublicGroupAccess accessRow >>= groupDomainClaim) businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} - gInfo = GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupDomainVerified = unBI <$> groupDomainVerified} + gInfo = GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupDomainVerified} in (gInfo, groupKeysRow) toGroupInfo_ :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 81ed6ac5a8..6fa071b81a 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -537,7 +537,7 @@ data GroupInfo = GroupInfo rosterVersion :: Maybe VersionRoster, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact, - groupDomainVerified :: Maybe Bool + groupDomainVerified :: Maybe DomainVerification } deriving (Eq, Show) @@ -831,14 +831,17 @@ data LocalProfile = LocalProfile localBadge :: Maybe LocalBadge, localAlias :: LocalAlias, contactDomain :: Maybe SimplexDomainClaim, - contactDomainVerified :: Maybe Bool + contactDomainVerified :: Maybe DomainVerification } deriving (Eq, Show) +data DomainVerification = DVFailed | DVVerified | DVMoved + deriving (Eq, Show) + localProfileId :: LocalProfile -> ProfileId localProfileId LocalProfile {profileId} = profileId -toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> Maybe Bool -> LocalProfile +toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> Maybe DomainVerification -> LocalProfile toLocalProfile profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, badge, contactDomain} localAlias now badgeVerified contactDomainVerified = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, localBadge, localAlias, contactDomain, contactDomainVerified} where @@ -2319,6 +2322,8 @@ $(JQ.deriveJSON defaultJSON ''UserContact) $(JQ.deriveJSON defaultJSON ''Profile) +$(JQ.deriveJSON (enumJSON $ dropPrefix "DV") ''DomainVerification) + $(JQ.deriveJSON defaultJSON ''LocalProfile) $(JQ.deriveJSON defaultJSON ''UserContactRequest) @@ -2388,6 +2393,13 @@ instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP instance ToField MsgFilter where toField = toField . msgFilterInt +instance FromField DomainVerification where + fromField = fromIntField_ $ \case + 0 -> Just DVFailed + 1 -> Just DVVerified + 2 -> Just DVMoved + _ -> Nothing + $(JQ.deriveJSON defaultJSON ''CReqClientData) $(JQ.deriveJSON defaultJSON ''MemberIdRole) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e3e58cdf49..5b16bde0d3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -73,7 +73,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), USDCents (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -218,7 +218,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation showFullLinks ccLink CRConnectionIncognitoUpdated u c customUserProfile -> ttyUser u $ viewConnectionIncognitoUpdated c customUserProfile testView CRConnectionUserChanged u c c' nu -> ttyUser u $ viewConnectionUserChanged showFullLinks u c nu c' - CRConnectionPlan u connLink _ otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName + CRConnectionPlan u connLink planSimplexName otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink planSimplexName connectionPlan <> otherSimplexNameNote otherSimplexName CRNewPreparedChat u (AChat _ (Chat cInfo _ _)) -> ttyUser u $ case cInfo of DirectChat ct -> [ttyContact' ct <> ": contact is prepared"] GroupChat g _ -> [ttyGroup' g <> ": group is prepared"] @@ -474,6 +474,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CEvtNameMoved {} -> [] CEvtGroupMemberUpdated {} -> [] CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) CEvtServiceRequest u reqId sigKey_ req -> @@ -1171,13 +1172,14 @@ viewDomainVerified nameType domain_ result = Just reason -> [plain nameStr <> " not verified: " <> plain reason] -- §4.7: show a peer's claimed name only with its verification context — "verified" / "verification --- failed" when a status is recorded, "unverified" when there is a proof but no status yet, and nothing +-- failed" / "moved" when a status is recorded, "unverified" when there is a proof but no status yet, and nothing -- at all when there is neither (an unproven, unverifiable claim is not shown). -simplexDomainLine :: SimplexNameType -> Maybe SimplexDomainClaim -> Maybe Bool -> [StyledString] +simplexDomainLine :: SimplexNameType -> Maybe SimplexDomainClaim -> Maybe DomainVerification -> [StyledString] simplexDomainLine _ Nothing _ = [] simplexDomainLine nameType (Just SimplexDomainClaim {domain, proof}) status = case status of - Just True -> [line "verified"] - Just False -> [line "verification failed"] + Just DVVerified -> [line "verified"] + Just DVFailed -> [line "verification failed"] + Just DVMoved -> [line "moved"] Nothing | isJust proof -> [line "unverified"] | otherwise -> [] @@ -2235,8 +2237,8 @@ otherSimplexNameNote = \case Just ni@(SimplexNameInfo NTContact _) -> [plain $ "You can also connect to " <> shortNameInfoStr ni <> " in direct chat"] Nothing -> [] -viewConnectionPlan :: ChatConfig -> ACreatedConnLink -> ConnectionPlan -> [StyledString] -viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case +viewConnectionPlan :: ChatConfig -> ACreatedConnLink -> Maybe SimplexNameInfo -> ConnectionPlan -> [StyledString] +viewConnectionPlan ChatConfig {logLevel, testView} _connLink planSimplexName = \case CPInvitationLink ilp -> case ilp of ILPOk contactSLinkData ov -> [invOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] ILPOwnLink -> [invLink "own link"] @@ -2255,7 +2257,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case Just ContactShortLinkData {business} | business -> ("business address: " <>) _ -> ("invitation link: " <>) - CPContactAddress cap -> case cap of + p@(CPContactAddress cap nc_) -> (<> viewNameChange (connectionPlanOwnLink p) nc_) $ case cap of CAPOk contactSLinkData ov -> [addrOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] CAPOwnLink -> [ctAddr "own address"] CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] @@ -2273,7 +2275,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case Just ContactShortLinkData {business} | business -> ("business address: " <>) _ -> ("contact address: " <>) - CPGroupLink glp -> case glp of + p@(CPGroupLink glp nc_) -> (<> viewNameChange (connectionPlanOwnLink p) nc_) $ case glp of GLPOk groupSLinkInfo_ groupSLinkData ov -> let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_ in [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] @@ -2312,6 +2314,17 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case Nothing -> "group" CPError e -> viewChatError False logLevel testView e where + viewNameChange :: Bool -> Maybe NameChange -> [StyledString] + viewNameChange own = \case + Just (NCLapsed w) -> maybe [] (\SimplexNameInfo {nameDomain} -> [viewNameWarning own nameDomain w]) planSimplexName + Just (NCMoved (AChatInfo _ c)) -> case c of + DirectChat ct -> ["known contact @" <> ttyContact' ct] + GroupChat g@GroupInfo {businessChat} _ + | isJust businessChat -> ["known business " <> ttyGroup' g] + | useRelays' g -> ["known channel " <> ttyGroup' g] + | otherwise -> ["known group " <> ttyGroup' g] + _ -> [] + Nothing -> [] nextConnectPrepared Contact {preparedContact, activeConn} = case preparedContact of Just _ -> maybe True (\c -> connStatus c == ConnPrepared) activeConn _ -> False @@ -2327,6 +2340,29 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case Just (OVFailed r) -> ["owner signature: FAILED (" <> plain r <> ")"] Nothing -> [] +viewNameWarning :: Bool -> SimplexDomain -> NameWarning -> StyledString +viewNameWarning own d = \case + NWExpired e g -> yours <> simplexNameStr d <> " expired on " <> plain (day e) <> maybe "" ((renewal <>) . plain . day) g + NWAvailable p -> yours <> simplexNameStr d <> " is no longer registered, available: " <> namePriceStr p + NWReservedForCommunity -> simplexNameStr d <> " is reserved for community" + NWNotRegistered -> simplexNameStr d <> " is not registered" + where + yours = if own then "your " else "" + renewal = if own then ", renew it before " else ", its owner can renew it until " + +viewNameNotConnectable :: SimplexDomain -> NameWarning -> StyledString +viewNameNotConnectable d = \case + NWAvailable p -> simplexNameStr d <> " is available: " <> namePriceStr p + w -> viewNameWarning False d w + +simplexNameStr :: SimplexDomain -> StyledString +simplexNameStr d = "SimpleX name " <> plain (fullDomainName d) + +namePriceStr :: NamePrice -> StyledString +namePriceStr NamePrice {amount = USDCents c, years} = + let (dollars, cents) = c `divMod` 100 + in plain $ "$" <> tshow dollars <> (if cents == 0 then "" else "." <> T.justifyRight 2 '0' (tshow cents)) <> " for " <> tshow years <> " years" + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, shortDescr, description, contactLink}} @@ -2795,6 +2831,7 @@ viewChatError isCmd logLevel testView = \case CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEInvalidConnReq -> viewInvalidConnReq + CESimplexDomainNotReady domain (SDENameWarning w) -> [viewNameNotConnectable domain w] CESimplexDomainNotReady domain domainErr -> let reason = case domainErr of SDENoValidLink -> "has no valid connection link" diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index 29a8c00c4f..5a04f7956a 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -5,12 +6,19 @@ module ChatTests.Names where import ChatClient import ChatTests.DBUtils -import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) +import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel', prepareChannel1Relay, waitQueuedLinkUpdates) import ChatTests.Utils import Control.Concurrent.Async (concurrently_) +import Data.Int (Int64) +import qualified Data.Map.Strict as M import qualified Data.Text as T +import Data.Time.Clock (UTCTime) import NameResolver +import Simplex.Chat.Controller (NamePrice (..), NameWarning (..)) +import Simplex.Chat.Library.Commands (nameRecordOrWarning) +import Simplex.Messaging.Names.Record (NamePricing (..), NameRecord, NameRegistration (..), NameReservedReason (..), USDCents (..)) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), SimplexNameType (..), SimplexTLD (..)) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..), roundedToUTCTime) import Test.Hspec hiding (it) chatNamesTests :: SpecWith TestParams @@ -20,8 +28,10 @@ chatNamesTests = do it "stopping address sharing removes the name" testAddressSharingOffRemovesName it "deleting the address removes the name" testAddressDeleteRemovesName it "connect by name not claimed in link profile is rejected" testConnectByNameNotClaimed + it "prepare with a name not claimed in link profile is not verified" testPrepareNameNotClaimed + it "prepared chat moved to another user sets the user's other chat with the name moved" testPrepareNameChangeUser 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 "connect by unregistered name reports it is available" testConnectByNameNotFound it "set name not resolving to own address is rejected" testSetNameNotOwnAddress it "channel name is not verified just by joining via link" testChannelDomainLinkJoinUnverified it "verify channel name, fail on re-point, retain status on refresh" testChannelDomainVerify @@ -29,6 +39,33 @@ chatNamesTests = do it "connect by name resolving to channel (primary) and direct contact" testConnectByNameChannelAndContact it "connect by name resolving to direct contact (primary) and channel" testConnectByNameContactAndChannel it "connect by name resolving to business (primary) and channel" testConnectByNameBusinessAndChannel + describe "connection plan: the name lookup answers" $ do + it "reserved for another reason" testPlanNameReservedOther + it "registered with no usable link" testPlanNameNoValidLink + it "known chat and own name, name moved to a new address" testPlanKnownNameAddressChanged + it "known chat, name moved, new chat opened" testPlanKnownNameNewChatOpened + it "known chat and own name, name moved to another known chat" testPlanKnownNameMovedToKnownChat + it "known chat, name moved to a known business chat" testPlanKnownNameMovedToBusinessChat + it "known chat, name now available" testPlanKnownNameAvailable + it "known chat and own name, name without link or reserved" testPlanKnownNameReserved + it "known chat and own name, the request failed" testPlanKnownNameResolverFailed + it "known chat and own name, the name's new link cannot be fetched" testPlanKnownNameLinkFailed + it "own channel expired, joined channel moved to a new channel, new channel joined" testPlanChannelNameMoved + it "joined channel moved to a channel with no relays" testPlanChannelNameMovedNoRelays + it "no local chat, the channel has no relays" testPlanNameChannelNoRelays + it "known chats, the name's link differs only in its key hash" testPlanNameLinkKeyHash + it "known chats, the name's link differs only in its server address" testPlanNameLinkServer + it "local chats, before and after the name expired" testPlanLocalChats + it "bare name, the other kind's chat moved, offered" testPlanNameOtherKindMoved + it "bare name, the other kind is a business chat" testPlanNameOtherKindBusiness + it "known chat, resolved on every call" testPlanKnownNameStale + it "no local chat, resolved on every call" testPlanNameResolvedEveryCall + it "own name, expired" testPlanOwnNameExpired + it "own name, now available" testPlanOwnNameAvailable + it "the request failed" testPlanNameResolverFailed + it "resolve=never: local hit and miss" testPlanNameResolveNever + describe "name warnings" $ do + it "registration record or warning" $ \_ -> testNameRecordOrWarning testConnectByName :: HasCallStack => TestParams -> IO () testConnectByName ps = withSmpServerAndNames ps $ \reg -> @@ -225,7 +262,7 @@ testConnectByNameNotFound ps = withSmpServerAndNames ps $ \_reg -> test _alice bob = do enableNamesRole bob bob ##> "/c @nobody.simplex" - bob .<## "smpErr = NAME {nameErr = NOT_FOUND}}" + bob <## "SimpleX name nobody.simplex is available: $20 for 2 years" testSetNameNotOwnAddress :: HasCallStack => TestParams -> IO () testSetNameNotOwnAddress ps = withSmpServerAndNames ps $ \reg -> @@ -260,6 +297,10 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames ps $ \reg -> bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=allGroups") bob <## "group link: known group #team" bob <## "use #team to send messages" -- no "SimpleX name" line: status stays unknown + bob ##> "/_connect plan 1 #team.simplex" + knownGroupPlan "team" "team" bob + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "team" "team" bob where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) @@ -341,6 +382,15 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames ps $ \reg -> alice <## "updated public group access: domain=team.simplex" cath <## "alice updated group #team: (signed)" cath <## "updated public group access: domain=team.simplex" + alice ##> "/_set domain 1 team.simplex" + alice <## "new contact address set" + alice ##> "/_connect plan 1 team.simplex" + alice <## "group link: own link for group #team" + alice <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 team.simplex" + bob <## "group link: ok to connect via relays" + _ <- getTermLine bob + bob <## "You can also connect to @team.simplex in direct chat" bob ##> "/c #team.simplex" bob <## "#team: connection started" concurrentlyN_ @@ -358,6 +408,14 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames ps $ \reg -> bob <## "SimpleX name: #team (verified)" bob <## "use #team to send messages" bob <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 #team.simplex" + knownGroupPlan "team" "team" bob + registerName reg teamName (contactNameRecord "team.simplex" (T.pack contactLink)) + bob ##> "/_connect plan 1 team.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "team" "team" bob where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) @@ -375,6 +433,9 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames ps $ \reg -> (channelLink, _) <- prepareChannel1Relay "acme" alice cath alice ##> "/ad" (contactLink, _) <- getContactLinks alice True + registerName reg acmeName (contactAndChannelNameRecord "acme.simplex" (T.pack $ otherLink contactLink) (T.pack channelLink)) + bob ##> "/_connect plan 1 acme.simplex" + bob <## "SimpleX name acme.simplex is not included in the connection link's profile" registerName reg acmeName (contactAndChannelNameRecord "acme.simplex" (T.pack contactLink) (T.pack channelLink)) alice ##> "/_set domain 1 acme.simplex" alice <## "new contact address set" @@ -382,6 +443,9 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames ps $ \reg -> bob <## "contact address: ok to connect" _ <- getTermLine bob -- contact short link data (JSON, printed in test view) bob <## "You can also join channel #acme" + alice ##> "/_connect plan 1 acme.simplex" + alice <## "contact address: own address" + alice <## "You can also join channel #acme" where acmeName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" []) @@ -421,6 +485,11 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames ps $ \reg -> bob ##> "/_connect plan 1 @biz.simplex resolve=never" bob <## "business address: known business #alice" bob <## "use #alice to send messages" + registerExpiredName reg bizName (contactAndChannelNameRecord "biz.simplex" (T.pack contactLink) (T.pack channelLink)) + bob ##> "/_connect plan 1 @biz.simplex" + bob <## "business address: known business #alice" + bob <## "use #alice to send messages" + bob <##. "SimpleX name biz.simplex expired on " -- the business's verified domain survives the handshake and is shown in group info bob ##> "/i #alice" bob <## "group ID: 1" @@ -428,3 +497,574 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames ps $ \reg -> bob <## "SimpleX name: @biz.simplex (verified)" where bizName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "biz" []) + +aliceSimplexName :: SimplexNameInfo +aliceSimplexName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) + +withAliceName :: HasCallStack => (NameRegistry -> (String, String) -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +withAliceName test ps = withSmpServerAndNames ps $ \reg -> + testChat2 aliceProfile bobProfile (setup reg) ps + where + setup reg alice bob = do + mapM_ enableNamesRole [alice, bob] + links <- setAliceName reg alice + test reg links alice bob + +setAliceName :: HasCallStack => NameRegistry -> TestCC -> IO (String, String) +setAliceName reg alice = do + alice ##> "/ad" + links@(shortLink, _) <- getContactLinks alice True + registerName reg aliceSimplexName (aliceRecord shortLink) + alice ##> "/_set domain 1 alice.simplex" + alice <## "new contact address set" + pure links + +aliceRecord :: String -> NameRecord +aliceRecord = contactNameRecord "alice.simplex" . T.pack + +knownAlicePlan :: HasCallStack => TestCC -> IO () +knownAlicePlan = knownContactPlan "alice" "alice.simplex" + +knownContactPlan :: HasCallStack => String -> String -> TestCC -> IO () +knownContactPlan c name cc = do + cc <## ("contact address: known contact " <> c) + cc <## ("SimpleX name: @" <> name <> " (verified)") + cc <## ("use @" <> c <> " to send messages") + +withKnownAliceName :: HasCallStack => (NameRegistry -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +withKnownAliceName test ps = withSmpServerAndNames ps $ \reg -> + testChat3 aliceProfile bobProfile cathProfile (setup reg) ps + where + setup reg alice bob cath = do + mapM_ enableNamesRole [alice, bob, cath] + _ <- setAliceName reg alice + connectBobByName "@alice.simplex" alice bob + test reg alice bob cath + +connectBobByName :: HasCallStack => String -> TestCC -> TestCC -> IO () +connectBobByName name alice bob = do + bob ##> ("/c " <> name) + bob <## "alice: connection started" + 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") + +testPrepareNameNotClaimed :: HasCallStack => TestParams -> IO () +testPrepareNameNotClaimed = withAliceName $ \_reg (shortLink, fullLink) _alice bob -> do + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " domain=bob.simplex " <> contactSLinkData) + bob <## "alice: contact is prepared" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + +testPrepareNameChangeUser :: HasCallStack => TestParams -> IO () +testPrepareNameChangeUser = withAliceName $ \_reg (shortLink, fullLink) alice bob -> do + connectBobByName "@alice.simplex" alice bob + bob ##> "/create user robert" + showActiveUser bob "robert" + bob ##> ("/_connect plan 2 " <> shortLink) + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 2 " <> fullLink <> " " <> shortLink <> " domain=alice.simplex " <> contactSLinkData) + bob <## "alice: contact is prepared" + bob ##> "/_set contact user @5 1" + bob <## "contact alice changed from user robert to user bob, new local name: alice_1" + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + bob <## "contact address: known prepared contact alice_1" + bob <## "SimpleX name: @alice.simplex (verified)" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + _ <- getTermLine bob + bob <## "SimpleX name: @alice.simplex (moved)" + 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" + _ <- getTermLine bob + pure () + +testPlanNameReservedOther :: HasCallStack => TestParams -> IO () +testPlanNameReservedOther = withAliceName $ \reg _r _alice bob -> do + registerReservedName reg acmeName NRRTrademark + bob ##> "/_connect plan 1 acme.simplex" + bob <## "SimpleX name acme.simplex is not registered" + where + acmeName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" []) + +testPlanNameNoValidLink :: HasCallStack => TestParams -> IO () +testPlanNameNoValidLink = withAliceName $ \reg _r _alice bob -> do + registerName reg boogalooName (channelNameRecord "boogaloo.simplex" "simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w") + bob ##> "/_connect plan 1 @boogaloo.simplex" + bob <## "SimpleX name boogaloo.simplex has no valid connection link" + where + boogalooName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "boogaloo" []) + +testPlanKnownNameAvailable :: HasCallStack => TestParams -> IO () +testPlanKnownNameAvailable = withAliceName $ \reg _r alice bob -> do + connectBobByName "@alice.simplex" alice bob + unregisterName reg aliceSimplexName + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <## "SimpleX name alice.simplex is no longer registered, available: $20 for 2 years" + +testPlanKnownNameReserved :: HasCallStack => TestParams -> IO () +testPlanKnownNameReserved = withAliceName $ \reg _r alice bob -> do + connectBobByName "@alice.simplex" alice bob + registerName reg aliceSimplexName (emptyRecord "alice.simplex") + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + registerExpiredName reg aliceSimplexName (emptyRecord "alice.simplex") + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <##. "SimpleX name alice.simplex expired on " + registerReservedName reg aliceSimplexName NRRTrademark + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + registerReservedName reg aliceSimplexName NRRCommunity + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <## "SimpleX name alice.simplex is reserved for community" + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + alice <## "SimpleX name alice.simplex is reserved for community" + +testPlanKnownNameResolverFailed :: HasCallStack => TestParams -> IO () +testPlanKnownNameResolverFailed = withAliceName $ \reg _r alice bob -> do + connectBobByName "@alice.simplex" alice bob + failNameResolution reg aliceSimplexName + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob ##> "/_connect plan 1 alice.simplex" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + +testPlanKnownNameStale :: HasCallStack => TestParams -> IO () +testPlanKnownNameStale = withAliceName $ \reg (shortLink, _) alice bob -> do + connectBobByName "@alice.simplex" alice bob + registerExpiredName reg aliceSimplexName (aliceRecord shortLink) + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + bob <##. "SimpleX name alice.simplex expired on " + registerName reg aliceSimplexName (aliceRecord shortLink) + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + +testPlanNameResolvedEveryCall :: HasCallStack => TestParams -> IO () +testPlanNameResolvedEveryCall = withAliceName $ \reg (shortLink, _) _alice bob -> do + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + bob ##> "/_connect plan 1 alice.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + registerExpiredName reg aliceSimplexName (aliceRecord shortLink) + bob ##> "/_connect plan 1 @alice.simplex" + bob <##. "SimpleX name alice.simplex expired on " + +testPlanOwnNameExpired :: HasCallStack => TestParams -> IO () +testPlanOwnNameExpired = withAliceName $ \reg (shortLink, _) alice _bob -> do + registerExpiredName reg aliceSimplexName (aliceRecord shortLink) + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + alice <##. "your SimpleX name alice.simplex expired on " + +testPlanOwnNameAvailable :: HasCallStack => TestParams -> IO () +testPlanOwnNameAvailable = withAliceName $ \reg _r alice _bob -> do + unregisterName reg aliceSimplexName + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + alice <## "your SimpleX name alice.simplex is no longer registered, available: $20 for 2 years" + +testPlanNameResolveNever :: HasCallStack => TestParams -> IO () +testPlanNameResolveNever = withAliceName $ \reg _r alice bob -> do + connectBobByName "@alice.simplex" alice bob + unregisterName reg aliceSimplexName + bob ##> "/_connect plan 1 alice.simplex resolve=never" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex resolve=never" + alice <## "contact address: own address" + bob ##> "/_connect plan 1 @nobody.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + bob ##> "/_connect plan 1 nobody.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + +testPlanKnownNameAddressChanged :: HasCallStack => TestParams -> IO () +testPlanKnownNameAddressChanged = withKnownAliceName $ \reg alice bob cath -> do + cath ##> "/ad" + (cathLink, _) <- getContactLinks cath True + registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack cathLink)) + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + cath ##> "/_set domain 1 alice.simplex" + cath <## "new contact address set" + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: ok to connect" + _ <- getTermLine alice + (alice "/_connect plan 1 @alice.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + bob <## "known contact @alice" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + knownAlicePlan bob + bob ##> "/c @alice.simplex" + bob <## "cath: connection started" + cath <## "bob (Bob) wants to connect to you!" + cath <## "to accept: /ac bob" + cath <## "to reject: /rc bob (the sender will NOT be notified)" + +testPlanKnownNameNewChatOpened :: HasCallStack => TestParams -> IO () +testPlanKnownNameNewChatOpened = withKnownAliceName $ \reg _alice bob cath -> do + (cathLink, cathFullLink) <- setAliceName reg cath + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob <## "known contact @alice" + bob ##> ("/_prepare contact 1 " <> cathFullLink <> " " <> cathLink <> " domain=alice.simplex " <> contactSLinkData) + bob <## "cath: contact is prepared" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + bob <## "contact address: known prepared contact cath" + bob <## "SimpleX name: @alice.simplex (verified)" + +testPlanKnownNameMovedToKnownChat :: HasCallStack => TestParams -> IO () +testPlanKnownNameMovedToKnownChat = withKnownAliceName $ \reg alice bob cath -> do + (cathLink, _) <- setAliceName reg cath + bob ##> ("/c " <> cathLink) + cath <#? bob + cath ##> "/ac bob" + cath <## "bob (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "cath (Catherine): contact is connected") + (cath <## "bob (Bob): contact is connected") + bob ##> "/_connect plan 1 @alice.simplex" + knownContactPlan "cath" "alice.simplex" bob + bob <## "known contact @alice" + alice ##> ("/c " <> cathLink) + cath <#? alice + cath ##> "/ac alice" + cath <## "alice (Alice): accepting contact request, you can send messages to contact" + concurrently_ + (alice <## "cath (Catherine): contact is connected") + (cath <## "alice (Alice): contact is connected") + alice ##> "/_connect plan 1 @alice.simplex" + knownContactPlan "cath" "alice.simplex" alice + (alice TestParams -> IO () +testPlanKnownNameMovedToBusinessChat = withKnownAliceName $ \reg _alice bob cath -> do + (cathLink, cathFullLink) <- setAliceName reg cath + cath ##> "/auto_accept on business" + cath <## "auto_accept on, business" + bob ##> ("/_connect plan 1 " <> cathLink) + bob <## "business address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> cathFullLink <> " " <> cathLink <> " " <> contactSLinkData) + bob <## "#cath: group is prepared" + bob ##> "/_connect group #1" + bob <## "#cath: connection started" + cath <## "#bob (Bob): accepting business address request..." + cath <## "#bob: bob_1 joined the group" + bob <## "#cath: joining the group..." + bob <## "#cath: you joined the group" + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "business address: known business #cath" + bob <## "use #cath to send messages" + bob <## "known contact @alice" + bob ##> "/_connect plan 1 @alice.simplex resolve=never" + bob <## "business address: known business #cath" + bob <## "use #cath to send messages" + +testPlanNameResolverFailed :: HasCallStack => TestParams -> IO () +testPlanNameResolverFailed = withAliceName $ \reg _r _alice bob -> do + failNameResolution reg brokenName + bob ##> "/_connect plan 1 broken.simplex" + bob .<## "smpErr = NAME {nameErr = RESOLVER {resolverErr = \"HTTP 500\"}}}" + where + brokenName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "broken" []) + +testPlanKnownNameLinkFailed :: HasCallStack => TestParams -> IO () +testPlanKnownNameLinkFailed = withKnownAliceName $ \reg alice bob cath -> do + _ <- setAliceName reg cath + cath ##> "/da" + cath <## "Your chat address is deleted - accepted contacts will remain connected." + cath <## "To create a new chat address use /ad" + bob ##> "/_connect plan 1 @alice.simplex" + knownAlicePlan bob + alice ##> "/_connect plan 1 @alice.simplex" + alice <## "contact address: own address" + cath ##> "/_connect plan 1 @alice.simplex" + cath <## "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + +testPlanChannelNameMoved :: HasCallStack => TestParams -> IO () +testPlanChannelNameMoved = withChannelChats $ \reg alice cath bob -> do + shortLink <- joinVerifiedTeam reg alice cath bob + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "team" "team" bob + registerExpiredName reg teamSimplexName (channelNameRecord "team.simplex" (T.pack shortLink)) + alice ##> "/_connect plan 1 #team.simplex" + alice <## "group link: own link for group #team" + alice <##. "your SimpleX name team.simplex expired on " + (shortLink2, fullLink2) <- prepareChannel' 2 "teamv2" alice cath + registerName reg teamSimplexName (channelNameRecord "team.simplex" (T.pack shortLink2)) + setChannelDomain alice cath "alice_1" "teamv2" "team.simplex" + bob ##> "/_connect plan 1 #team.simplex" + bob <## "group link: ok to connect via relays" + _ <- getTermLine bob + bob <## "known channel #team" + memberJoinChannel' "teamv2" 2 1 1 1 [cath] [alice] shortLink2 fullLink2 bob + bob ##> "/_verify domain #2" + bob <## "SimpleX name #team verified" + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "teamv2" "team" bob + alice ##> "/_connect plan 1 #team.simplex resolve=never" + alice <## "group link: own link for group #teamv2" + +testPlanChannelNameMovedNoRelays :: HasCallStack => TestParams -> IO () +testPlanChannelNameMovedNoRelays = withChannelChats $ \reg alice cath bob -> do + _ <- joinVerifiedTeam reg alice cath bob + (shortLink2, _) <- prepareChannel' 2 "team2" alice cath + registerName reg teamSimplexName (channelNameRecord "team.simplex" (T.pack shortLink2)) + setChannelDomain alice cath "alice_1" "team2" "team.simplex" + cath ##> "/leave #team2" + cath <## "#team2: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team2 to allow future invitations" + cath <## "use /d #team2 to delete the group (also clears the rejection)" + alice <## "#team2: cath_1 left the group (signed)" + waitQueuedLinkUpdates alice + bob ##> ("/_connect plan 1 " <> shortLink2) + bob <## "group link: channel has no active relays, please try to join later" + bob ##> "/_connect plan 1 #team.simplex" + bob <## "group link: channel has no active relays, please try to join later" + bob <## "known channel #team" + +testPlanNameChannelNoRelays :: HasCallStack => TestParams -> IO () +testPlanNameChannelNoRelays = withChannelChats $ \reg alice cath bob -> do + (channelLink, _) <- prepareChannel1Relay "team" alice cath + alice ##> "/ad" + (contactLink, _) <- getContactLinks alice True + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) + cath ##> "/leave #team" + cath <## "#team: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team to allow future invitations" + cath <## "use /d #team to delete the group (also clears the rejection)" + alice <## "#team: cath left the group (signed)" + waitQueuedLinkUpdates alice + bob ##> "/_connect plan 1 #team.simplex" + bob <## "SimpleX name team.simplex is not included in the connection link's profile" + bob ##> "/_connect plan 1 team.simplex" + bob <## "SimpleX name team.simplex is not included in the connection link's profile" + alice ##> "/public group access #team domain=team.simplex" + alice <## "updated public group access: domain=team.simplex" + waitQueuedLinkUpdates alice + bob ##> "/_connect plan 1 #team.simplex" + bob <## "group link: channel has no active relays, please try to join later" + bob ##> "/_connect plan 1 team.simplex" + bob <## "group link: channel has no active relays, please try to join later" + bob <## "You can also connect to @team.simplex in direct chat" + alice ##> "/_set domain 1 team.simplex" + alice <## "new contact address set" + bob ##> "/_connect plan 1 @team.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + bob ##> "/_connect plan 1 @nobody.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + +testPlanNameLinkKeyHash :: HasCallStack => TestParams -> IO () +testPlanNameLinkKeyHash = withTeamChats $ \reg contactLink channelLink _alice bob -> do + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink <> "?c=LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI") (T.pack channelLink)) + bob ##> "/_connect plan 1 team.simplex" + knownGroupPlan "team" "team" bob + bob <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "team" "team" bob + bob ##> "/_connect plan 1 @team.simplex" + knownContactPlan "alice" "team.simplex" bob + (bob TestParams -> IO () +testPlanNameLinkServer ps = flip withTeamChats ps $ \reg contactLink channelLink _alice bob -> do + let withPort l = T.pack $ l <> "?p=" <> smpTestPort ps <> "&c=LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI" + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (withPort contactLink) (withPort channelLink)) + bob ##> "/_connect plan 1 @team.simplex" + knownContactPlan "alice" "team.simplex" bob + (bob "/_connect plan 1 #team.simplex" + knownGroupPlan "team" "team" bob + (bob TestParams -> IO () +testPlanLocalChats = withTeamChats $ \reg contactLink channelLink alice bob -> do + bob ##> "/_connect plan 1 team.simplex resolve=never" + knownGroupPlan "team" "team" bob + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownGroupPlan "team" "team" bob + bob ##> "/_connect plan 1 @team.simplex resolve=never" + knownContactPlan "alice" "team.simplex" bob + bob ##> "/_connect plan 1 team.simplex" + knownGroupPlan "team" "team" bob + bob <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 @team.simplex" + knownContactPlan "alice" "team.simplex" bob + registerExpiredName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) + bob ##> "/_connect plan 1 team.simplex" + knownGroupPlan "team" "team" bob + bob <##. "SimpleX name team.simplex expired on " + bob ##> "/_connect plan 1 team.simplex resolve=never" + knownGroupPlan "team" "team" bob + alice ##> "/_connect plan 1 team.simplex resolve=never" + alice <## "group link: own link for group #team" + alice ##> "/_connect plan 1 @team.simplex resolve=never" + alice <## "contact address: own address" + +otherLink :: String -> String +otherLink l = case break (== '#') l of + (pre, '#' : c : rest) -> pre <> "#" <> [if c == 'A' then 'B' else 'A'] <> rest + _ -> error "no link key" + +teamSimplexName :: SimplexNameInfo +teamSimplexName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) + +setChannelDomain :: HasCallStack => TestCC -> TestCC -> String -> String -> String -> IO () +setChannelDomain owner relay ownerName g domain = do + owner ##> ("/public group access #" <> g <> " domain=" <> domain) + owner <## ("updated public group access: domain=" <> domain) + relay <## (ownerName <> " updated group #" <> g <> ": (signed)") + relay <## ("updated public group access: domain=" <> domain) + +joinVerifiedTeam :: HasCallStack => NameRegistry -> TestCC -> TestCC -> TestCC -> IO String +joinVerifiedTeam reg alice cath bob = do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath + registerName reg teamSimplexName (channelNameRecord "team.simplex" (T.pack shortLink)) + setChannelDomain alice cath "alice" "team" "team.simplex" + memberJoinChannel "team" [cath] [alice] shortLink fullLink bob + bob ##> "/_verify domain #1" + bob <## "SimpleX name #team verified" + pure shortLink + +knownGroupPlan :: HasCallStack => String -> String -> TestCC -> IO () +knownGroupPlan g name cc = do + cc <## ("group link: known group #" <> g) + cc <## ("SimpleX name: #" <> name <> " (verified)") + cc <## ("use #" <> g <> " to send messages") + +testPlanNameOtherKindMoved :: HasCallStack => TestParams -> IO () +testPlanNameOtherKindMoved = withTeamChats $ \reg _contactLink channelLink alice bob -> do + bob ##> "/_connect plan 1 @team.simplex" + knownContactPlan "alice" "team.simplex" bob + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/ad" + (contactLink2, _) <- getContactLinks alice True + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink2) (T.pack channelLink)) + alice ##> "/_set domain 2 team.simplex" + alice <## "new contact address set" + bob ##> "/_connect plan 1 team.simplex" + knownGroupPlan "team" "team" bob + bob <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 @team.simplex" + bob <## "contact address: ok to connect" + _ <- getTermLine bob + bob <## "known contact @alice" + +testPlanNameOtherKindBusiness :: HasCallStack => TestParams -> IO () +testPlanNameOtherKindBusiness = withChannelChats $ \reg alice cath bob -> do + (channelLink, _) <- prepareChannel1Relay "biz" alice cath + alice ##> "/ad" + (contactLink, fullLink) <- getContactLinks alice True + registerName reg bizName (contactAndChannelNameRecord "biz.simplex" (T.pack contactLink) (T.pack channelLink)) + alice ##> "/auto_accept on business" + alice <## "auto_accept on, business" + setChannelDomain alice cath "alice" "biz" "biz.simplex" + alice ##> "/_set domain 1 biz.simplex" + alice <## "new contact address set" + bob ##> "/_connect plan 1 @biz.simplex" + bob <## "business address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> contactLink <> " domain=biz.simplex " <> contactSLinkData) + bob <## "#alice: group is prepared" + bob ##> "/_connect group #1" + bob <## "#alice: connection started" + alice <## "#bob (Bob): accepting business address request..." + bob <## "#alice: joining the group..." + alice <## "#bob: bob_1 joined the group" + bob <## "#alice: you joined the group" + joinChannelByName "biz" alice cath bob + bob ##> "/_connect plan 1 biz.simplex resolve=never" + knownGroupPlan "biz" "biz" bob + bob ##> "/_connect plan 1 biz.simplex" + knownGroupPlan "biz" "biz" bob + bob <## "You can also connect to @biz.simplex in direct chat" + where + bizName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "biz" []) + +withChannelChats :: HasCallStack => (NameRegistry -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +withChannelChats test ps = withSmpServerAndNames ps $ \reg -> + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] + test reg alice cath bob + +joinChannelByName :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () +joinChannelByName g alice cath bob = do + bob ##> ("/c #" <> g <> ".simplex") + bob <## ("#" <> g <> ": connection started") + concurrentlyN_ + [ bob <### [ConsoleString ("#" <> g <> ": joining the group (connecting to relay cath)..."), ConsoleString ("#" <> g <> ": you joined the group (connected to relay cath)")], + do + cath <## ("bob (Bob): accepting request to join group #" <> g <> "...") + cath <## ("#" <> g <> ": bob joined the group"), + alice <### [EndsWith "(Bob) in the channel"] + ] + +withTeamChats :: HasCallStack => (NameRegistry -> String -> String -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +withTeamChats test = withChannelChats $ \reg alice cath bob -> do + (channelLink, _) <- prepareChannel1Relay "team" alice cath + alice ##> "/ad" + (contactLink, _) <- getContactLinks alice True + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) + setChannelDomain alice cath "alice" "team" "team.simplex" + alice ##> "/_set domain 1 team.simplex" + alice <## "new contact address set" + connectBobByName "@team.simplex" alice bob + joinChannelByName "team" alice cath bob + test reg contactLink channelLink alice bob + +testNameRecordOrWarning :: IO () +testNameRecordOrWarning = do + recordOrWarning (registered Nothing Nothing record) `shouldBe` Right record + recordOrWarning (registered (Just 1100) Nothing record) `shouldBe` Right record + recordOrWarning (NRRegistered Nothing Nothing (Just NRRCommunity) record) `shouldBe` Right record + recordOrWarning (registered (Just 900) (Just 2000) record) `shouldBe` Left (NWExpired (utc 900) (Just $ utc 2000)) + recordOrWarning (registered (Just 900) Nothing record) `shouldBe` Left (NWExpired (utc 900) Nothing) + recordOrWarning (registered (Just 800) (Just 900) record) `shouldBe` Left (NWExpired (utc 800) Nothing) + recordOrWarning (NRAvailable $ pricing 5 M.empty) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 2000) 2) + recordOrWarning (NRAvailable $ pricing 3 (M.fromList [(5, USDCents 5000)])) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 10000) 2) + recordOrWarning (NRAvailable $ pricing 6 M.empty) `shouldBe` Left NWNotRegistered + recordOrWarning (NRReserved NRRCommunity) `shouldBe` Left NWReservedForCommunity + recordOrWarning (NRReserved NRRTrademark) `shouldBe` Left NWNotRegistered + where + recordOrWarning = nameRecordOrWarning (RoundedSystemTime 1000) (SimplexDomain TLDSimplex "alice" []) + registered expires graceUntil = NRRegistered (RoundedSystemTime <$> expires) (RoundedSystemTime <$> graceUntil) Nothing + pricing minLabelLength registrationPrices = NamePricing {registrationPrices, basePrice = USDCents 1000, minLabelLength} + record = contactNameRecord "alice.simplex" "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok" + +utc :: Int64 -> UTCTime +utc = roundedToUTCTime . RoundedSystemTime diff --git a/tests/NameResolver.hs b/tests/NameResolver.hs index 3020e95833..54ac1f1a87 100644 --- a/tests/NameResolver.hs +++ b/tests/NameResolver.hs @@ -3,12 +3,17 @@ -- | Local HTTP names resolver for chat tests, copied from simplexmq's -- NamesResolverServer and made dynamic: it answers /v2/resolve/ from a --- mutable name -> NameRecord registry, so a test can resolve a name to the +-- mutable name -> answer registry, so a test can resolve a name to the -- address it just created. module NameResolver ( NameRegistry, withNameResolver, registerName, + registerExpiredName, + registerReservedName, + unregisterName, + failNameResolution, + emptyRecord, contactNameRecord, channelNameRecord, contactAndChannelNameRecord, @@ -22,37 +27,64 @@ import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) -import Network.HTTP.Types (hContentType, notFound404, ok200) +import Network.HTTP.Types (hContentType, internalServerError500, notFound404, ok200) import Network.Wai (Application, pathInfo, responseLBS) import qualified Network.Wai.Handler.Warp as Warp import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Names.Record (NamePricing (..), NameRecord (..), NameRegistration (..), NameResponse (..), USDCents (..)) +import Simplex.Messaging.Names.Record (NamePricing (..), NameRecord (..), NameRegistration (..), NameReservedReason, NameResponse (..), USDCents (..)) import Simplex.Messaging.Server.Names (NamesConfig (..)) import Simplex.Messaging.SimplexName (SimplexDomain (..), SimplexNameInfo (..), labelHash) +import Simplex.Messaging.SystemTime (RoundedSystemTime (..), getSystemSeconds) -type NameRegistry = TVar (Map Text NameRecord) +data TestNameAnswer = AnswerRegistration NameRegistration | AnswerFails + +type NameRegistry = TVar (Map Text TestNameAnswer) -- | Run an action with a local resolver on a free port and its registry (keyed -- by the query the resolver looks the name up by). -withNameResolver :: (Int -> TVar (Map Text NameRecord) -> IO a) -> IO a +withNameResolver :: (Int -> NameRegistry -> IO a) -> IO a withNameResolver action = do reg <- newTVarIO M.empty Warp.withApplication (pure (app reg)) $ \port -> action port reg where - app :: TVar (Map Text NameRecord) -> Application + app :: NameRegistry -> Application app reg req send = do (st, body) <- case pathInfo req of ["health"] -> pure (ok200, "{}") - ["v2", "resolve", q] -> (\r -> (ok200, J.encode $ nameResponse r)) . M.lookup q <$> readTVarIO reg + ["v2", "resolve", q] -> answer . M.lookup q <$> readTVarIO reg _ -> pure (notFound404, "{}") send $ responseLBS st [(hContentType, "application/json")] body - nameResponse (Just nameRecord) = NameResponse {lastBlockTs = Nothing, registration = NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord}} - nameResponse Nothing = NameResponse {lastBlockTs = Nothing, registration = NRAvailable {pricing = NamePricing {registrationPrices = M.empty, basePrice = USDCents 1000, minLabelLength = 1}}} + answer (Just AnswerFails) = (internalServerError500, "{}") + answer (Just (AnswerRegistration registration)) = (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration}) + answer Nothing = (ok200, J.encode NameResponse {lastBlockTs = Nothing, registration = NRAvailable {pricing = NamePricing {registrationPrices = M.empty, basePrice = USDCents 1000, minLabelLength = 1}}}) -- | Register a name's domain to resolve to the given record. -registerName :: TVar (Map Text NameRecord) -> SimplexNameInfo -> NameRecord -> IO () -registerName reg SimplexNameInfo {nameDomain = SimplexDomain {nameTLD, domain}} r = - atomically $ modifyTVar' reg $ M.insert (decodeLatin1 $ strEncode (labelHash domain) <> strEncode nameTLD) r +registerName :: NameRegistry -> SimplexNameInfo -> NameRecord -> IO () +registerName reg ni nameRecord = + registerAnswer reg ni $ AnswerRegistration NRRegistered {expires = Nothing, graceUntil = Nothing, reservedReason_ = Nothing, nameRecord} + +registerAnswer :: NameRegistry -> SimplexNameInfo -> TestNameAnswer -> IO () +registerAnswer reg ni a = atomically $ modifyTVar' reg $ M.insert (registryKey ni) a + +registerExpiredName :: NameRegistry -> SimplexNameInfo -> NameRecord -> IO () +registerExpiredName reg ni nameRecord = do + RoundedSystemTime now <- getSystemSeconds + let expires = Just $ RoundedSystemTime (now - 86400) + graceUntil = Just $ RoundedSystemTime (now + 30 * 86400) + registerAnswer reg ni $ AnswerRegistration NRRegistered {expires, graceUntil, reservedReason_ = Nothing, nameRecord} + +registerReservedName :: NameRegistry -> SimplexNameInfo -> NameReservedReason -> IO () +registerReservedName reg ni reservedReason = registerAnswer reg ni $ AnswerRegistration NRReserved {reservedReason} + +failNameResolution :: NameRegistry -> SimplexNameInfo -> IO () +failNameResolution reg ni = registerAnswer reg ni AnswerFails + +unregisterName :: NameRegistry -> SimplexNameInfo -> IO () +unregisterName reg ni = atomically $ modifyTVar' reg $ M.delete (registryKey ni) + +registryKey :: SimplexNameInfo -> Text +registryKey SimplexNameInfo {nameDomain = SimplexDomain {nameTLD, domain}} = + decodeLatin1 $ strEncode (labelHash domain) <> strEncode nameTLD contactNameRecord :: Text -> Text -> NameRecord contactNameRecord name link = (emptyRecord name) {nrSimplexContact = [link]}