From 6bc2f4450e720cac12f65ac0b95c60d5e146d207 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 30 Sep 2026 20:11:51 +0200 Subject: [PATCH] align and simplify core/UI/UXcanvas --- apps/ios/Shared/Model/AppAPITypes.swift | 10 +- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Shared/Views/ChatList/ChatListView.swift | 66 ++---- .../ios/Shared/Views/Helpers/ShareSheet.swift | 16 ++ .../Views/NewChat/NewChatMenuButton.swift | 6 +- .../Shared/Views/NewChat/NewChatView.swift | 205 +++++++----------- .../chat/simplex/common/model/SimpleXAPI.kt | 10 +- .../common/views/chatlist/ChatListView.kt | 43 +--- .../common/views/newchat/ConnectPlan.kt | 136 +++++------- .../common/views/newchat/NewChatSheet.kt | 6 +- .../src/Directory/Service.hs | 8 +- bots/api/COMMANDS.md | 4 + bots/api/TYPES.md | 2 - .../types/typescript/src/responses.ts | 2 + .../types/typescript/src/types.ts | 2 - .../src/simplex_chat/types/_responses.py | 2 + .../src/simplex_chat/types/_types.py | 2 - plans/2026-09-22-name-lookup-core-api.md | 8 +- plans/2026-09-28-name-warnings.md | 46 ++-- .../2026-09-18-names-lookup-flows.excalidraw | 76 ++++--- src/Simplex/Chat/Controller.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 191 +++++++++------- src/Simplex/Chat/Library/Internal.hs | 20 +- src/Simplex/Chat/Store/Groups.hs | 15 ++ src/Simplex/Chat/View.hs | 6 +- tests/ChatTests/Names.hs | 202 ++++++++++++++++- 26 files changed, 634 insertions(+), 460 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index acffafa990..7fe33940d0 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -853,7 +853,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connLink: CreatedConnLink?, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan) + case connectionPlan(user: UserRef, connLink: CreatedConnLink?, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan, localChats: [ChatInfo]?, offerLookup: Bool?) case newPreparedChat(user: UserRef, chat: ChatData) case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact) case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo) @@ -977,7 +977,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)") case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))") - case let .connectionPlan(u, connLink, _, _, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") + case let .connectionPlan(u, connLink, _, _, connectionPlan, _, _): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))") case let .newPreparedChat(u, chat): return withUser(u, String(describing: chat)) case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))") case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))") @@ -1448,6 +1448,8 @@ struct ConnectionPlanResult { var planSimplexName: SimplexNameInfo? var otherSimplexName: SimplexNameInfo? var connectionPlan: ConnectionPlan + var localChats: [ChatInfo] + var offerLookup: Bool } // APIConnectPlan resolution scope; .never is local-store-only (no network), used for per-keystroke name search @@ -1489,7 +1491,7 @@ enum InvitationLinkPlan: Decodable, Hashable { } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool, existingChat_: ChatInfo?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1504,7 +1506,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable { } enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool, existingChat_: ChatInfo?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..07f7ec0463 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1046,8 +1046,8 @@ func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, l return nil } let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, resolveMode: resolveMode, linkOwnerSig: linkOwnerSig), inProgress: inProgress) - if case let .result(.connectionPlan(_, connLink, planSimplexName, otherSimplexName, connPlan)) = r { - return ConnectionPlanResult(connLink: connLink, planSimplexName: planSimplexName, otherSimplexName: otherSimplexName, connectionPlan: connPlan) + if case let .result(.connectionPlan(_, connLink, planSimplexName, otherSimplexName, connPlan, localChats, offerLookup)) = r { + return ConnectionPlanResult(connLink: connLink, planSimplexName: planSimplexName, otherSimplexName: otherSimplexName, connectionPlan: connPlan, localChats: localChats ?? [], offerLookup: offerLookup ?? false) } // a .never (typing) search that matches nothing locally is not an error to surface if case .error(.error(.notResolvedLocally)) = r { return nil } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 190f7d1e8b..d35ab88937 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -774,8 +774,7 @@ struct ChatListSearchBar: View { name: candidate, searchFocussed: $searchFocussed, dismiss: false, - filterKnownContact: { searchChatFilteredBySimplexLink = [$0.id] }, - filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] } + filterChats: filterChats ) } else { ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) } @@ -819,8 +818,7 @@ struct ChatListSearchBar: View { name: candidate, searchFocussed: $searchFocussed, dismiss: false, - filterKnownContact: { searchChatFilteredBySimplexLink = [$0.id] }, - filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] } + filterChats: filterChats ) } } @@ -858,17 +856,13 @@ struct ChatListSearchBar: View { nameSearchTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 300_000_000) 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] = [] - 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) } + let result = await apiConnectPlan(connLink: candidate, resolveMode: .never, inProgress: BoxedValue(false)) + if Task.isCancelled { return } + if let result { + addMissingChats(result.localChats) + _ = filterChats(result.localChats) + if !result.offerLookup { connectNameCandidate = nil } } - searchChatFilteredBySimplexLink = Set(ids) - // drop the row only when every searched type is already known locally - if ids.count == targets.count { connectNameCandidate = nil } } } else if t != "" { searchFocussed = true @@ -912,10 +906,14 @@ struct ChatListSearchBar: View { searchText = "" searchFocussed = false }, - filterKnownContact: { searchChatFilteredBySimplexLink = [$0.id] }, - filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] } + filterChats: filterChats ) } + + private func filterChats(_ chats: [ChatInfo]) -> Bool { + searchChatFilteredBySimplexLink = Set(chats.map { $0.id }) + return true + } } // Row shown when the search text is a SimpleX name — in place of the list tags in the chat list, below @@ -926,8 +924,7 @@ struct ConnectByNameRow: View { var name: String @FocusState.Binding var searchFocussed: Bool var dismiss: Bool - var filterKnownContact: ((Contact) -> Void)? = nil - var filterKnownGroup: ((GroupInfo) -> Void)? = nil + var filterChats: (([ChatInfo]) -> Bool)? = nil var body: some View { HStack(spacing: 4) { @@ -945,8 +942,7 @@ struct ConnectByNameRow: View { name, theme: theme, dismiss: dismiss, - filterKnownContact: filterKnownContact, - filterKnownGroup: filterKnownGroup + filterChats: filterChats ) } } @@ -963,36 +959,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 56e437e5c8..13ece8c358 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/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 5d183bc67d..53448da42f 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -447,7 +447,11 @@ struct ContactsListSearchBar: View { searchText = "" searchFocussed = false }, - filterKnownContact: { searchChatFilteredBySimplexLink = $0.id } + filterChats: { chats in + guard chats.allSatisfy({ $0.contact != nil }) else { return false } + searchChatFilteredBySimplexLink = chats.first?.id + return true + } ) } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 22a65bdf9a..60da1fa671 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1016,46 +1016,11 @@ private func showAskCurrentOrIncognitoProfileSheet( ) } -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?() - } - ) - ]} - ) -} - private func showOwnGroupLinkConfirmConnectSheet( groupInfo: GroupInfo, connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, onConnectOther: (() -> Void)? = nil, dismiss: Bool, @@ -1063,9 +1028,10 @@ private func showOwnGroupLinkConfirmConnectSheet( ) { if groupInfo.useRelays { showSheet( + NSLocalizedString("Your channel", comment: "new chat action") + "\n" + String.localizedStringWithFormat( NSLocalizedString("This is your link for channel %@!", comment: "new chat action"), - groupInfo.displayName + groupInfo.displayName + (planSimplexName.map { " (\($0.shortStr))" } ?? "") ), actions: { var actions = [ @@ -1136,9 +1102,10 @@ private func showPrepareContactAlert( connectionLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, ownerVerification: OwnerVerification? = nil, - verifiedDomain: SimplexDomain? = nil, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, connectOtherLink: String? = nil, + filterChats: (([ChatInfo]) -> Bool)? = nil, addressChanged: Bool = false, openExistingChat: (() -> Void)? = nil, theme: AppTheme, @@ -1159,8 +1126,9 @@ private func showPrepareContactAlert( size: alertProfileImageSize ), profileBadge: contactShortLinkData.localBadge, + nameCaption: planSimplexName?.shortStr, theme: theme, - subtitle: addressChanged ? verifiedDomain.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new address.", comment: "alert subtitle"), $0.fullDomainName) } : nil, + subtitle: addressChanged ? planSimplexName.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new address.", comment: "alert subtitle"), $0.nameDomain.fullDomainName) } : nil, information: ownerVerificationMessage(ownerVerification), cancelTitle: openExistingChat == nil ? NSLocalizedString("Cancel", comment: "new chat action") : NSLocalizedString("Open existing chat", comment: "alert action"), confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"), @@ -1169,7 +1137,7 @@ 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) @@ -1184,7 +1152,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, filterChats: filterChats) } } ) } @@ -1194,9 +1162,10 @@ private func showPrepareGroupAlert( groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, ownerVerification: OwnerVerification? = nil, - verifiedDomain: SimplexDomain? = nil, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, connectOtherLink: String? = nil, + filterChats: (([ChatInfo]) -> Bool)? = nil, addressChanged: Bool = false, openExistingChat: (() -> Void)? = nil, theme: AppTheme, @@ -1204,7 +1173,7 @@ private func showPrepareGroupAlert( cleanup: (() -> Void)? ) { let isChannel = !(groupShortLinkInfo?.direct ?? true) - let channelChanged = addressChanged ? verifiedDomain.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new channel.", comment: "alert information"), $0.fullDomainName) } : nil + let channelChanged = addressChanged ? planSimplexName.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new channel.", comment: "alert information"), $0.nameDomain.fullDomainName) } : nil let information = [channelChanged, ownerVerificationMessage(ownerVerification)].compactMap { $0 }.joined(separator: "\n") let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } showOpenChatAlert( @@ -1218,6 +1187,7 @@ private func showPrepareGroupAlert( : "person.2.circle.fill", size: alertProfileImageSize ), + nameCaption: planSimplexName?.shortStr, theme: theme, subtitle: isChannel ? subscriberCount : nil, information: information.isEmpty ? nil : information, @@ -1230,7 +1200,7 @@ 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 { @@ -1249,7 +1219,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, filterChats: filterChats) } } ) } @@ -1258,8 +1228,10 @@ private func showOpenKnownContactAlert( _ contact: Contact, theme: AppTheme, dismiss: Bool, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, - connectOtherLink: String? = nil + connectOtherLink: String? = nil, + filterChats: (([ChatInfo]) -> Bool)? = nil ) { showOpenChatAlert( profileName: contact.profile.displayName, @@ -1271,6 +1243,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: @@ -1282,7 +1255,7 @@ private func showOpenKnownContactAlert( openKnownContact(contact, dismiss: dismiss, cleanup: nil) }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss) } + { planAndConnect(link, theme: theme, dismiss: dismiss, filterChats: filterChats) } } ) } @@ -1305,8 +1278,10 @@ private func showOpenKnownGroupAlert( _ groupInfo: GroupInfo, theme: AppTheme, dismiss: Bool, + planSimplexName: SimplexNameInfo? = nil, connectOtherButton: String? = nil, - connectOtherLink: String? = nil + connectOtherLink: String? = nil, + filterChats: (([ChatInfo]) -> Bool)? = nil ) { let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" } showOpenChatAlert( @@ -1318,6 +1293,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 @@ -1339,7 +1315,7 @@ private func showOpenKnownGroupAlert( openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) }, onSecond: connectOtherLink.map { link in - { planAndConnect(link, theme: theme, dismiss: dismiss) } + { planAndConnect(link, theme: theme, dismiss: dismiss, filterChats: filterChats) } } ) } @@ -1426,7 +1402,7 @@ private func showNameWarningAlert( } } -private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { +private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?, filterChats: (([ChatInfo]) -> Bool)?) { showAlert( String.localizedStringWithFormat( otherSimplexName.nameType == .publicGroup @@ -1437,7 +1413,7 @@ private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOthe ), actions: {[ UIAlertAction(title: connectOtherButton, style: .default) { _ in - planAndConnect(otherSimplexName.shortStr, theme: theme, dismiss: dismiss, cleanup: cleanup) + planAndConnect(otherSimplexName.shortStr, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) }, okAlertAction ]} @@ -1451,8 +1427,7 @@ func planAndConnect( theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? = nil, - filterKnownContact: ((Contact) -> Void)? = nil, - filterKnownGroup: ((GroupInfo) -> Void)? = nil + filterChats: (([ChatInfo]) -> Bool)? = nil ) { switch strConnectTarget(shortOrFullLink) { case let .link(_, linkType, _): @@ -1486,6 +1461,8 @@ func planAndConnect( if let result { let connectionPlan = result.connectionPlan let planSimplexName = result.planSimplexName + let localChats = result.localChats + await MainActor.run { addMissingChats(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 @@ -1504,26 +1481,8 @@ func planAndConnect( if let nameWarning, let nameDomain { await MainActor.run { var openExisting: (() -> Void)? = nil - switch connectionPlan { - case let .contactAddress(.known(contact), _): - if ChatModel.shared.getContactChat(contact.contactId) == nil { - ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) - } - if let f = filterKnownContact { - f(contact) - } else { - openExisting = { openKnownContact(contact, dismiss: dismiss, cleanup: cleanup) } - } - case let .groupLink(.known(groupInfo), _): - if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil { - ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) - } - if let f = filterKnownGroup { - f(groupInfo) - } else { - openExisting = { openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) } - } - default: () + if let chatInfo = localChats.first, filterChats?(localChats) != true { + openExisting = { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } } showNameWarningAlert(domain: nameDomain, warning: nameWarning, openExistingChat: openExisting, cleanup: cleanup) } @@ -1578,9 +1537,7 @@ func planAndConnect( logger.debug("planAndConnect, .invitationLink, .connecting") await MainActor.run { if let contact = contact_ { - if let f = filterKnownContact { - f(contact) - } else { + if filterChats?(localChats) != true { showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) } } else { @@ -1590,28 +1547,31 @@ func planAndConnect( case let .known(contact): logger.debug("planAndConnect, .invitationLink, .known") await MainActor.run { - if let f = filterKnownContact { - f(contact) - } else { + if filterChats?(localChats) != true { showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) } } } case let .contactAddress(cap, _): switch cap { - case let .ok(contactSLinkData_, ownerVerification, addressChanged, existingChat_): + case let .ok(contactSLinkData_, ownerVerification, addressChanged): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { + var openExisting: (() -> Void)? = nil + if let chatInfo = localChats.first, filterChats?(localChats) != true { + openExisting = { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } + } showPrepareContactAlert( connectionLink: connectionLink, contactShortLinkData: contactSLinkData, ownerVerification: ownerVerification, - verifiedDomain: planSimplexName?.nameDomain, + planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, + filterChats: filterChats, addressChanged: addressChanged, - openExistingChat: (filterKnownContact == nil ? existingChat_ : nil).map { chatInfo in { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } }, + openExistingChat: openExisting, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1639,7 +1599,7 @@ func planAndConnect( connectionLink: connectionLink, connectionPlan: connectionPlan, connectOtherButton: connectOtherButton, - onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } }, + onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } }, dismiss: dismiss, cleanup: cleanup ) @@ -1652,6 +1612,8 @@ func planAndConnect( actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, + connectOtherButton: connectOtherButton, + onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } }, dismiss: dismiss, cleanup: cleanup ) @@ -1659,53 +1621,50 @@ func planAndConnect( case let .connectingProhibit(contact): logger.debug("planAndConnect, .contactAddress, .connectingProhibit") await MainActor.run { - if let f = filterKnownContact { - f(contact) - } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + if filterChats?(localChats) != true { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, filterChats: filterChats) } } 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) + if filterChats?(localChats) == true { if let otherSimplexName = result.otherSimplexName, let connectOtherButton { - showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, filterChats: filterChats) } } case let .contactViaAddress(contact): logger.debug("planAndConnect, .contactAddress, .contactViaAddress") await MainActor.run { - showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet( - contact: contact, - dismiss: dismiss, - cleanup: cleanup - ) + if filterChats?(localChats) != true { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, filterChats: filterChats) + } } } case let .groupLink(glp, _): switch glp { - case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, addressChanged, existingChat_): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, addressChanged): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { + var openExisting: (() -> Void)? = nil + if let chatInfo = localChats.first, filterChats?(localChats) != true { + openExisting = { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } + } showPrepareGroupAlert( connectionLink: connectionLink, groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, ownerVerification: ownerVerification, - verifiedDomain: planSimplexName?.nameDomain, + planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, + filterChats: filterChats, addressChanged: addressChanged, - openExistingChat: (filterKnownGroup == nil ? existingChat_ : nil).map { chatInfo in { openKnownChat(chatInfo.id, dismiss: dismiss, cleanup: cleanup) } }, + openExistingChat: openExisting, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1727,18 +1686,18 @@ func planAndConnect( case let .ownLink(groupInfo): logger.debug("planAndConnect, .groupLink, .ownLink") await MainActor.run { - if let f = filterKnownGroup { - f(groupInfo) + if filterChats?(localChats) == true { if let otherSimplexName = result.otherSimplexName, let connectOtherButton { - showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } } else { showOwnGroupLinkConfirmConnectSheet( groupInfo: groupInfo, connectionLink: connectionLink, connectionPlan: connectionPlan, + planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, - onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) } }, + onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } }, dismiss: dismiss, cleanup: cleanup ) @@ -1752,6 +1711,8 @@ func planAndConnect( actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, + connectOtherButton: connectOtherButton, + onConnectOther: connectOtherLink.map { link in { planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } }, dismiss: dismiss, cleanup: cleanup ) @@ -1764,16 +1725,12 @@ 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) + if filterChats?(localChats) == true { if let otherSimplexName = result.otherSimplexName, let connectOtherButton { - showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup) + showOtherNameAlert(otherSimplexName, connectOtherButton: connectOtherButton, theme: theme, dismiss: dismiss, cleanup: cleanup, filterChats: filterChats) } } else { - showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink) + showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, planSimplexName: planSimplexName, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, filterChats: filterChats) } } case let .noRelays(groupSLinkData_): @@ -1850,21 +1807,6 @@ 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?, @@ -1903,6 +1845,13 @@ private func connectViaLink( } } +func addMissingChats(_ chats: [ChatInfo]) { + let m = ChatModel.shared + for cInfo in chats where !m.hasChat(cInfo.id) { + m.addChat(Chat(chatInfo: cInfo, chatItems: [])) + } +} + func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) { if let c = ChatModel.shared.getContactChat(contact.contactId) { openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup) 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 3a77f58582..188743a172 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 @@ -1633,7 +1633,7 @@ object ChatController { suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState): ConnectionPlanResult? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress) - if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan) + if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan, r.res.localChats, r.res.offerLookup) // a PRMNever (typing) search that matches nothing locally is not an error to surface if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.NotResolvedLocally) return null if (inProgress.value && r != null) apiConnectResponseAlert(r) @@ -6712,7 +6712,7 @@ sealed class CR { @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() - @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink? = null, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink? = null, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan, val localChats: List = emptyList(), val offerLookup: Boolean = false): CR() @Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR() @Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR() @Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR() @@ -7426,6 +7426,8 @@ data class ConnectionPlanResult( val planSimplexName: SimplexNameInfo?, val otherSimplexName: SimplexNameInfo?, val connectionPlan: ConnectionPlan, + val localChats: List, + val offerLookup: Boolean, ) // APIConnectPlan resolution scope; PRMNever is local-store-only (no network), used for per-keystroke name search @@ -7472,7 +7474,7 @@ sealed class InvitationLinkPlan { @Serializable sealed class ContactAddressPlan { - @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val addressChanged: Boolean = false, val existingChat_: ChatInfo? = null): ContactAddressPlan() + @Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val addressChanged: Boolean = false): ContactAddressPlan() @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @@ -7482,7 +7484,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val addressChanged: Boolean = false, val existingChat_: ChatInfo? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val addressChanged: Boolean = false): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() 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 9c46cc48c4..03c05a9a7b 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 @@ -850,17 +850,16 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState searchChatFilteredBySimplexLink.value = emptySet() 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. + // search when the next keystroke arrives. 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 result = chatModel.controller.apiConnectPlan(rhId, candidate, PlanResolveMode.PRMNever, inProgress = inProgress) + if (result != null) { + addMissingChats(rhId, result.localChats) + searchChatFilteredBySimplexLink.value = result.localChats.map { it.id }.toSet() + if (!result.offerLookup) connectNameCandidate.value = null } - searchChatFilteredBySimplexLink.value = ids.toSet() - if (ids.size == targets.size) connectNameCandidate.value = null } else if (!searchShowingSimplexLink.value || it.isEmpty()) { if (it.isNotEmpty()) { focusRequester.requestFocus() @@ -889,8 +888,10 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState< planAndConnect( chatModel.remoteHostId(), link, - filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) }, - filterKnownGroup = { searchChatFilteredBySimplexLink.value = setOf(it.id) }, + filterChats = { chats -> + searchChatFilteredBySimplexLink.value = chats.map { it.id }.toSet() + true + }, close = null, cleanup = cleanup, ) @@ -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. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index d3c26baba4..d66decc6ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -34,8 +34,7 @@ suspend fun planAndConnect( linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, - filterKnownContact: ((Contact) -> Unit)? = null, - filterKnownGroup: ((GroupInfo) -> Unit)? = null, + filterChats: ((List) -> Boolean)? = null, ): CompletableDeferred { when (val target = strConnectTarget(shortOrFullLink.trim())) { is ConnectTarget.Link -> { @@ -58,7 +57,7 @@ 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, filterChats, inProgress) } private fun nameDate(t: Instant): String = @@ -156,8 +155,7 @@ private suspend fun planAndConnectTask( linkOwnerSig: LinkOwnerSig? = null, close: (() -> Unit)?, cleanup: (() -> Unit)? = null, - filterKnownContact: ((Contact) -> Unit)? = null, - filterKnownGroup: ((GroupInfo) -> Unit)? = null, + filterChats: ((List) -> Boolean)? = null, inProgress: MutableState ): CompletableDeferred { val completable = CompletableDeferred() @@ -174,7 +172,8 @@ private suspend fun planAndConnectTask( connectProgressManager.stopConnectProgress() if (!inProgress.value) { return completable } if (result != null) { - val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result + val (connectionLink, planSimplexName, otherSimplexName, connectionPlan, localChats) = result + addMissingChats(rhId, 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 @@ -190,22 +189,7 @@ private suspend fun planAndConnectTask( else -> null to null } if (nameWarning != null && nameDomain != null) { - val knownContact = ((connectionPlan as? ConnectionPlan.ContactAddress)?.contactAddressPlan as? ContactAddressPlan.Known)?.contact - val knownGroup = ((connectionPlan as? ConnectionPlan.GroupLink)?.groupLinkPlan as? GroupLinkPlan.Known)?.groupInfo - var openExisting: (() -> Unit)? = null - if (knownContact != null) { - if (chatModel.getContactChat(knownContact.contactId) == null) { - chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(knownContact), chatItems = emptyList())) - } - if (filterKnownContact != null) filterKnownContact(knownContact) - else openExisting = { openKnownContact(chatModel, rhId, close, knownContact) } - } else if (knownGroup != null) { - if (chatModel.getGroupChat(knownGroup.groupId) == null) { - chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(knownGroup, groupChatScope = null), chatItems = emptyList())) - } - if (filterKnownGroup != null) filterKnownGroup(knownGroup) - else openExisting = { openKnownGroup(chatModel, rhId, close, knownGroup) } - } + val openExisting: (() -> Unit)? = localChats.firstOrNull()?.takeIf { filterChats?.invoke(localChats) != true }?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())) } } showNameWarningAlert(rhId, nameDomain, nameWarning, openExisting, cleanup) return completable } @@ -251,9 +235,7 @@ private suspend fun planAndConnectTask( Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting") val contact = connectionPlan.invitationLinkPlan.contact_ if (contact != null) { - if (filterKnownContact != null) { - filterKnownContact(contact) - } else { + if (filterChats?.invoke(localChats) != true) { showOpenKnownContactAlert(chatModel, rhId, close, contact) cleanup() } @@ -269,9 +251,7 @@ private suspend fun planAndConnectTask( is InvitationLinkPlan.Known -> { Log.d(TAG, "planAndConnect, .InvitationLink, .Known") val contact = connectionPlan.invitationLinkPlan.contact - if (filterKnownContact != null) { - filterKnownContact(contact) - } else { + if (filterChats?.invoke(localChats) != true) { showOpenKnownContactAlert(chatModel, rhId, close, contact) cleanup() } @@ -290,9 +270,10 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.contactAddressPlan.addressChanged, - openExistingChat = if (filterKnownContact == null) connectionPlan.contactAddressPlan.existingChat_?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } } else null, + openExistingChat = localChats.firstOrNull()?.takeIf { filterChats?.invoke(localChats) != true }?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } }, close, - cleanup + cleanup, + filterChats ) } else { Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, no short link data") @@ -305,6 +286,7 @@ private suspend fun planAndConnectTask( ownerVerification = connectionPlan.contactAddressPlan.ownerVerification, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, + filterChats = filterChats, ) } ContactAddressPlan.OwnLink -> { @@ -317,6 +299,7 @@ private suspend fun planAndConnectTask( cleanup = cleanup, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, + filterChats = filterChats, ) } ContactAddressPlan.ConnectingConfirmReconnect -> { @@ -329,32 +312,24 @@ private suspend fun planAndConnectTask( cleanup = cleanup, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, + filterChats = filterChats, ) } is ContactAddressPlan.ConnectingProhibit -> { Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit") val contact = connectionPlan.contactAddressPlan.contact - if (filterKnownContact != null) { - filterKnownContact(contact) - } else { - showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) + if (filterChats?.invoke(localChats) != true) { + showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, filterChats = filterChats) cleanup() } } 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) - if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) + if (filterChats?.invoke(localChats) == true) { + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup, filterChats) } else { - showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) + showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, filterChats = filterChats) cleanup() } } @@ -362,14 +337,9 @@ 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())) - } - if (filterKnownContact != null) { - filterKnownContact(contact) - } else { - showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) + // connection request + if (filterChats?.invoke(localChats) != true) { + showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, filterChats = filterChats) cleanup() } } @@ -388,9 +358,10 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.groupLinkPlan.addressChanged, - openExistingChat = if (filterKnownGroup == null) connectionPlan.groupLinkPlan.existingChat_?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } } else null, + openExistingChat = localChats.firstOrNull()?.takeIf { filterChats?.invoke(localChats) != true }?.let { chatInfo -> { openChat_(chatModel, rhId, close, Chat(remoteHostId = rhId, chatInfo = chatInfo, chatItems = emptyList())); cleanup() } }, close, - cleanup + cleanup, + filterChats ) } else { Log.d(TAG, "planAndConnect, .GroupLink, .Ok, no short link data") @@ -403,16 +374,16 @@ private suspend fun planAndConnectTask( ownerVerification = connectionPlan.groupLinkPlan.ownerVerification, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, + filterChats = filterChats, ) } is GroupLinkPlan.OwnLink -> { Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink") val groupInfo = connectionPlan.groupLinkPlan.groupInfo - if (filterKnownGroup != null) { - filterKnownGroup(groupInfo) - if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) + if (filterChats?.invoke(localChats) == true) { + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup, filterChats) } else { - ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) + ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, filterChats = filterChats) } } GroupLinkPlan.ConnectingConfirmReconnect -> { @@ -425,6 +396,7 @@ private suspend fun planAndConnectTask( cleanup = cleanup, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, + filterChats = filterChats, ) } is GroupLinkPlan.ConnectingProhibit -> { @@ -454,16 +426,10 @@ 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) - if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup) + if (filterChats?.invoke(localChats) == true) { + if (otherSimplexName != null && connectOtherButton != null) showOtherNameAlert(rhId, otherSimplexName, connectOtherButton, close, cleanup, filterChats) } else { - showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink) + showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, filterChats = filterChats) cleanup() } } @@ -593,6 +559,7 @@ fun askCurrentOrIncognitoProfileAlert( ownerVerification: OwnerVerification? = null, connectOtherButton: String? = null, connectOtherLink: String? = null, + filterChats: ((List) -> Boolean)? = null, ) { val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null } AlertManager.privacySensitive.showAlertDialogButtonsColumn( @@ -620,7 +587,7 @@ fun askCurrentOrIncognitoProfileAlert( if (connectOtherButton != null && connectOtherLink != null) { SectionItemView({ AlertManager.privacySensitive.hideAlert() - withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) } + withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup, filterChats = filterChats) } }) { Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -638,6 +605,14 @@ fun askCurrentOrIncognitoProfileAlert( ) } +suspend fun addMissingChats(rhId: Long?, chats: List) { + chats.forEach { + if (!chatModel.chatsContext.hasChat(rhId, it.id)) { + chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = it, chatItems = emptyList())) + } + } +} + fun openChat_(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, chat: Chat) { withBGApi { close?.invoke() @@ -651,7 +626,7 @@ val alertProfileImageSize = 138.dp private fun nameWithDomain(name: String, planSimplexName: SimplexNameInfo?): String = name + (planSimplexName?.let { " (${it.shortStr})" } ?: "") -private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) { +private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null, filterChats: ((List) -> Boolean)? = null) { AlertManager.privacySensitive.showOpenChatAlert( profileName = contact.profile.displayName, profileFullName = contact.profile.fullName, @@ -670,7 +645,7 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: openKnownContact(chatModel, rhId, close, contact) }, connectOtherButton = connectOtherButton, - onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } }, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, filterChats = filterChats) } } }, onDismiss = null ) } @@ -697,6 +672,7 @@ fun ownGroupLinkConfirmConnect( planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null, + filterChats: ((List) -> Boolean)? = null, ) { if (groupInfo.useRelays) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( @@ -714,7 +690,7 @@ fun ownGroupLinkConfirmConnect( if (connectOtherButton != null && connectOtherLink != null) { SectionItemView({ AlertManager.privacySensitive.hideAlert() - withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) } + withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup, filterChats = filterChats) } }) { Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -777,7 +753,7 @@ fun ownGroupLinkConfirmConnect( } } -private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) { +private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null, filterChats: ((List) -> Boolean)? = null) { val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupInfo.groupProfile.displayName, @@ -817,7 +793,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( openKnownGroup(chatModel, rhId, close, groupInfo) }, connectOtherButton = connectOtherButton, - onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } }, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, filterChats = filterChats) } } }, onDismiss = null ) } @@ -843,7 +819,8 @@ fun showPrepareContactAlert( addressChanged: Boolean = false, openExistingChat: (() -> Unit)? = null, close: (() -> Unit)?, - cleanup: (() -> Unit)? + cleanup: (() -> Unit)?, + filterChats: ((List) -> Boolean)? = null ) { AlertManager.privacySensitive.showOpenChatAlert( profileName = contactShortLinkData.profile.displayName, @@ -880,7 +857,7 @@ fun showPrepareContactAlert( } }, connectOtherButton = connectOtherButton, - onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } }, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup, filterChats = filterChats) } } }, dismissText = generalGetString(if (openExistingChat != null) MR.strings.connect_plan_open_existing_chat else MR.strings.cancel_verb), onDismissButton = openExistingChat, onDismiss = { @@ -889,7 +866,7 @@ fun showPrepareContactAlert( ) } -private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?) { +private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?, filterChats: ((List) -> Boolean)?) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( title = String.format( generalGetString(if (otherSimplexName.nameType == SimplexNameType.publicGroup) MR.strings.simplex_name_also_leads_to_channel else MR.strings.simplex_name_also_leads_to_contact), @@ -901,7 +878,7 @@ private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, c Column { SectionItemView({ AlertManager.privacySensitive.hideAlert() - withBGApi { planAndConnect(rhId, otherSimplexName.shortStr, close = close, cleanup = cleanup) } + withBGApi { planAndConnect(rhId, otherSimplexName.shortStr, close = close, cleanup = cleanup, filterChats = filterChats) } }) { Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } @@ -925,7 +902,8 @@ fun showPrepareGroupAlert( addressChanged: Boolean = false, openExistingChat: (() -> Unit)? = null, close: (() -> Unit)?, - cleanup: (() -> Unit)? + cleanup: (() -> Unit)?, + filterChats: ((List) -> Boolean)? = null ) { val isChannel = !(groupShortLinkInfo?.direct ?: true) val subscriberCount = if (isChannel) groupShortLinkData.publicGroupData?.publicMemberCount?.let { subscriberCountStr(it) } else null @@ -971,7 +949,7 @@ fun showPrepareGroupAlert( } }, connectOtherButton = connectOtherButton, - onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } }, + onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup, filterChats = filterChats) } } }, dismissText = generalGetString(if (openExistingChat != null) MR.strings.connect_plan_open_existing_chat else MR.strings.cancel_verb), onDismissButton = openExistingChat, onDismiss = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 44aa3537bd..0ac5ccb5c0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -598,7 +598,11 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState< planAndConnect( chatModel.remoteHostId(), link, - filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) }, + filterChats = { chats -> + val contacts = chats.all { it is ChatInfo.Direct } + if (contacts) searchChatFilteredBySimplexLink.value = chats.map { it.id }.toSet() + contacts + }, close = close, cleanup = cleanup, ) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index cdb6ff8db5..40be36d7ab 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -544,7 +544,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)) PRMAll 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 @@ -777,7 +777,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)) PRMAll 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' @@ -915,7 +915,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o mId = MemberId oIdBytes gt' = groupTypeStr gt sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAll (Just ownerSig)) >>= \case - Right (CRConnectionPlan _ (Just (ACCL SCMContact ccLink)) _ _ plan) -> + Right (CRConnectionPlan _ (Just (ACCL SCMContact ccLink)) _ _ plan _ _) -> handleGroupLinkPlan ct ccLink mId ownerSig gt' plan _ -> sendMessage cc ct "Error: could not connect. Please report it to directory admins." deChatLinkReceived ct (MCLGroup {groupProfile = GroupProfile {publicGroup = Just pg}}) _ = @@ -1252,7 +1252,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 _ (Just (ACCL SCMContact ccLink)) _ _ (CPGroupLink glp _)) -> case glp of + Right (CRConnectionPlan _ (Just (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/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 3d9f7ede14..ec47839b33 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1563,6 +1563,8 @@ ConnectionPlan: Connection link information. - planSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? - otherSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? - connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan) +- localChats: [[ChatInfo](./TYPES.md#chatinfo)] +- offerLookup: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -1672,6 +1674,8 @@ ConnectionPlan: Connection link information. - planSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? - otherSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)? - connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan) +- localChats: [[ChatInfo](./TYPES.md#chatinfo)] +- offerLookup: bool SentInvitationToContact: Invitation sent to contact (when connecting via SimpleX name to a known contact address).. - type: "sentInvitationToContact" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 5770f87991..f285b7f1ea 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1930,7 +1930,6 @@ Ok: - contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? - addressChanged: bool -- existingChat_: [ChatInfo](#chatinfo)? OwnLink: - type: "ownLink" @@ -2542,7 +2541,6 @@ Ok: - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? - addressChanged: bool -- existingChat_: [ChatInfo](#chatinfo)? OwnLink: - type: "ownLink" diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 566b9123a4..0ae054dfec 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -211,6 +211,8 @@ export namespace CR { planSimplexName?: T.SimplexNameInfo otherSimplexName?: T.SimplexNameInfo connectionPlan: T.ConnectionPlan + localChats: T.ChatInfo[] + offerLookup: boolean } export interface ContactAlreadyExists extends Interface { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 1e052d6369..9102711b58 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2211,7 +2211,6 @@ export namespace ContactAddressPlan { contactSLinkData_?: ContactShortLinkData ownerVerification?: OwnerVerification addressChanged: boolean - existingChat_?: ChatInfo } export interface OwnLink extends Interface { @@ -2853,7 +2852,6 @@ export namespace GroupLinkPlan { groupSLinkData_?: GroupShortLinkData ownerVerification?: OwnerVerification addressChanged: boolean - existingChat_?: ChatInfo } export interface OwnLink extends Interface { diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index 41a1c3252c..d6b67f9772 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -65,6 +65,8 @@ class ConnectionPlan(TypedDict): planSimplexName: NotRequired["T.SimplexNameInfo"] otherSimplexName: NotRequired["T.SimplexNameInfo"] connectionPlan: "T.ConnectionPlan" + localChats: list["T.ChatInfo"] + offerLookup: bool class ContactAlreadyExists(TypedDict): type: Literal["contactAlreadyExists"] 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 101f6aa307..963da7c158 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1537,7 +1537,6 @@ class ContactAddressPlan_ok(TypedDict): contactSLinkData_: NotRequired["ContactShortLinkData"] ownerVerification: NotRequired["OwnerVerification"] addressChanged: bool - existingChat_: NotRequired["ChatInfo"] class ContactAddressPlan_ownLink(TypedDict): type: Literal["ownLink"] @@ -1985,7 +1984,6 @@ class GroupLinkPlan_ok(TypedDict): groupSLinkData_: NotRequired["GroupShortLinkData"] ownerVerification: NotRequired["OwnerVerification"] addressChanged: bool - existingChat_: NotRequired["ChatInfo"] class GroupLinkPlan_ownLink(TypedDict): type: Literal["ownLink"] diff --git a/plans/2026-09-22-name-lookup-core-api.md b/plans/2026-09-22-name-lookup-core-api.md index a88f6080aa..2cc667c2e7 100644 --- a/plans/2026-09-22-name-lookup-core-api.md +++ b/plans/2026-09-22-name-lookup-core-api.md @@ -26,7 +26,7 @@ The only UI work here is removing the name cache both apps kept (§9). This is t As of `db779eff4`, #7525 was a declaration-only sketch: it added `nameRegistration_`, `CPSimplexName` (renamed `CPNameNotConnectable` in `54bc83d80`) and `PRMAll` to `Controller.hs` and changed two arities, but no producer was written and no consumer was updated, so **the branch did not compile**. `resolveNameRecord` (`Commands.hs:5082-5088`) collapsed every non-`NRRegistered` answer into `NAME NOT_FOUND`, so expiry, price and reserved-reason — the substance of nine of the sixteen states — never left core. -The declared types were close to right. Twelve states mapped onto them as they stood, and of the four that did not, three are settled by wording or by `NameWarning` (§4, §7). The work was therefore mostly **producer**: stop discarding the registration, consult the existing by-name store lookups when the registry yields no usable link, and attach a warning to whichever plan comes out, as `nameWarning_ :: Maybe NameWarning` in place of the declared `nameRegistration_`. Two fields were genuinely missing (`addressChanged` and `existingChat_`, for 3c) and one constructor needed its domain (`CPNameNotConnectable`, so the UI can print the bare name the canvas shows). +The declared types were close to right. Twelve states mapped onto them as they stood, and of the four that did not, three are settled by wording or by `NameWarning` (§4, §7). The work was therefore mostly **producer**: stop discarding the registration, consult the existing by-name store lookups when the registry yields no usable link, and attach a warning to whichever plan comes out, as `nameWarning_ :: Maybe NameWarning` in place of the declared `nameRegistration_`. Two fields were genuinely missing for 3c (`addressChanged`, and the local chat to open, now `localChats` on the response, N32 of the name warnings plan) and one constructor needed its domain (`CPNameNotConnectable`, so the UI can print the bare name the canvas shows). `getContactToConnect` / `getGroupToConnect` (`Direct.hs:802`, `Groups.hs:1090`) and `getUserContactLinkViaTarget` already accept `CTName` and query by domain. The by-name lookup that `CPNameNotConnectable`'s own precondition needs therefore exists — the `CTDomain` branch reached it, but when it found nothing and the registry gave no usable link, it threw instead of answering. @@ -62,11 +62,11 @@ The changes are to the types in `Controller.hs`, including `PlanResolveMode`, an `planSimplexName` cannot serve here. It is a `SimplexNameInfo`, which needs a `nameType`, and an unregistered name has none — today's code invents one by trying `NTPublicGroup` then `NTContact` (`Commands.hs:4432-4434`), which is arbitrary and becomes visible the moment the UI renders it. The canvas writes every band-2 body as a bare name (`sunflower.simplex is available…`, `bakery.simplex expired on…`), never `@`/`#`, so the UI wants `fullDomainName`, not `shortStr`. -**`CAPOk` and `GLPOk` gain `addressChanged :: Bool` and `existingChat_ :: Maybe AChatInfo`.** +**`CAPOk` and `GLPOk` gain `addressChanged :: Bool`.** The local chat to open is the first of `CRConnectionPlan`'s `localChats` (N32 of `plans/2026-09-28-name-warnings.md`). ```haskell -| CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} -| GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} +| CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool} +| GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool} ``` `addressChanged` is true when the name resolved to a link that differs from the one held by the local chat, own address or own channel that claims this name. This is state 3c and nothing else expresses it: both "a link you do not have" and "a link that replaced yours" are `CAPOk` today. It states that the address moved and not who moved it, which is exactly what the canvas claims ("Only the address change is known, not who made it") and is the narrow form of the `nameOwnerChanged` field dropped in `f3bcd4a16` — no owner identity is carried, stored or compared. diff --git a/plans/2026-09-28-name-warnings.md b/plans/2026-09-28-name-warnings.md index 16cf9436ba..e121814c41 100644 --- a/plans/2026-09-28-name-warnings.md +++ b/plans/2026-09-28-name-warnings.md @@ -47,7 +47,7 @@ The canvas was reviewed against this model on 2026-09-28, story by story (§4). - **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:** equal after the server address is normalized (`serverShortLink`); + - **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:** @@ -86,6 +86,8 @@ From `plans/sketches/2026-09-18-names-lookup-flows.excalidraw`. | live, link L | own at another link | L's plan, `addressChanged` | — | 3c | | live, link L whose profile does not claim `d` | nothing | error `SDEUnknownDomain` | — | 2g, 4b | | live, link L whose profile does not claim `d` | chat or own | the local one | — | 3a, 4a | +| live, link L that cannot be joined yet (no relays, needs an app update, connecting) | chat or own at another link | the local one (N35) | — | 3a, 4a | +| live, link L at another chat the user has | chat or own at another link | that chat (N35) | — | 3a | | live, no link of kind K | nothing | not connectable | `NWNoValidLink` | 2f | | live, no link of kind K | chat or own | the local one | — | 3a, 4a | | expired | nothing | not connectable | `NWExpired` | 2b | @@ -111,12 +113,12 @@ The match is what the channel kind's local lookup found, else what the contact k | Local | Answer | |---|---| | `resolve=never` | the match, or `CENotResolvedLocally` | -| default mode, and the match is a fresh chat | the chat, from the store | +| default mode, the match is a fresh chat, and the other kind is local too | the chat, from the store (N28) | | any other match: own, a chat that is not fresh, or `resolve=all` | the resolved registration, planned as the typed name of the match's kind (the rows above) | -| nothing local | the channel's plan if the name has a live channel link, falling back to the contact kind if it fails and the name has a live contact link; otherwise the contact kind's plan if the name has a live contact link; otherwise not connectable with the "nothing" row's warning; a failed request is an error | +| nothing local | the channel's plan if the name has a live channel link, falling back to the contact kind if it fails, has no relays or needs an app update, and the name has a live contact link (N29); otherwise the contact kind's plan if the name has a live contact link; otherwise not connectable with the "nothing" row's warning; a failed request is an error | When the name is resolved and has a live link of the kind not planned, `otherSimplexName` is that kind's name, unless the user has a chat, own address or own channel of that kind at that link, or the channel was planned and failed (N22). The plan's screen shows it: -- the second button of 2a, 3c and 4a, and, from a message, of a chat's or own channel's alert; +- the second button of 2a, 3c and 4a, and, from the new chat sheet, of a chat's or own channel's alert (a name in a message always has `@` or `#`); - 3e for a chat or own channel, from search (new). ## 4. Changes against today @@ -126,10 +128,10 @@ When the name is resolved and has a live link of the kind not planned, `otherSim | # | Story | Today | New | |---|---|---|---| | 1 | The name is live and also reserved for community, whatever is local | the community alert instead of the plan | the plan, as for any live name | -| 4 | Bare name; a chat that is not fresh; the name also leads to the other kind at a link the user has no chat at (e.g. channel `#bakery`, the name now has only a contact link; or contact `@bakery`, the name has both) | the channel's plan if the name has a channel link, else the contact's; the local chat is shown only as the other kind's button, if at all | the local chat; from search, 3e: "bakery.simplex also leads to …", with Join channel or Connect; from a message, the chat's alert with that button | +| 4 | Bare name; a chat that is not fresh; the name also leads to the other kind at a link the user has no chat at (e.g. channel `#bakery`, the name now has only a contact link; or contact `@bakery`, the name has both) | the channel's plan if the name has a channel link, else the contact's; the local chat is shown only as the other kind's button, if at all | the local chat; from search, 3e: "bakery.simplex also leads to …", with Join channel or Connect; from the new chat sheet, the chat's alert with that button | | 7 | A chat; the name leads to a new link whose profile does not claim the name | the "Unconfirmed name" error alert | the chat, no alert | | 8 | A chat that is not fresh; the request fails | the "SimpleX name error" alert | the chat, no alert | -| 9 | A chat; the name leads to a new link; from a message | 3c with Open new chat, Cancel | 3c with Open new chat, Open existing chat (opens the plan's `existingChat_`), and no Cancel | +| 9 | A chat; the name leads to a new link; from a message | 3c with Open new chat, Cancel | 3c with Open new chat, Open existing chat (opens the first of `localChats`), and no Cancel | | 10 | A chat; the name is available | "Name no longer registered", "from $X per year" | the same alert, "$Y for 2 years" | | 11 | Own address or channel; the name leads to another link | "Connect to yourself?", or the own channel | 3c: "alice.simplex now leads to a new address", as for a chat, including 9 for the own channel (N20) | | 14 | Own; the name is available | "Your name has expired", "from $X per year" | the same alert, "$Y for 2 years" | @@ -158,7 +160,8 @@ data NameWarning data NamePrice = NamePrice {amount :: USDCents, years :: Int} ``` -- `expiredAt` is present whenever a name is expired, because a registration without `expires` counts as live. A missing `graceUntil` is the canvas's dateless variant. +- `expiredAt` is present whenever a name is expired, because a registration without `expires` counts as live. A missing `graceUntil` is the canvas's dateless variant, and a `graceUntil` that has passed is dropped (N30). +- **Local chats.** `CRConnectionPlan` has `localChats :: [AChatInfo]`: the chats the plan is about, the planned one first, and for a name every local chat of the looked-up kinds. It also has `offerLookup :: Bool`, false only when every looked-up kind is a fresh chat that is not the user's own (N31, N32). - `NamePrice` is the price of the 2-year term (N3): the registry's per-year price for the label's length (or its base price), times `years = 2`. - The domain is not repeated. It is `planSimplexName`, or `simplexDomain` on `CPNameNotConnectable`. @@ -191,7 +194,7 @@ Each returns the plan for what it finds, and whether it is fresh. It reads the c 5. `nameLinkOrWarning` gives either: - **a link L:** - own at L is answered as found, and a chat at L is confirmed (`setContactDomainVerified`, `setGroupDomainVerified`, or the channel's refresh from its link data); - - otherwise the plan for L, with `addressChanged` and `existingChat_` (the local chat or own channel) if something was local; if L's profile does not claim the name and something was local, answer with it instead; + - otherwise the plan for L, with `addressChanged` if something was local; if L's profile does not claim the name, or L's channel has no relays or needs an app update, or a connection via L is in progress, and something was local, answer with it instead (N35); a chat the user has at L is answered as that chat; - **a warning:** the local plan with `setNameWarning`, or `CPNameNotConnectable d` with the warning. **A link target** keeps today's steps: a local chat is answered, and `resolve=all` refreshes a known channel from its link data. @@ -200,11 +203,11 @@ Each returns the plan for what it finds, and whether it is fresh. It reads the c 1. Look up both kinds locally. The match is what the channel kind's lookup found, else what the contact kind's found. 2. With `resolve=never`, answer with the match, or fail with `CENotResolvedLocally`. -3. In the default mode, answer with the match if it is a fresh chat. +3. In the default mode, answer with the match if it is a fresh chat and the other kind is local too (N28). 4. Resolve the registration once. If the request fails, answer with the match if it is a chat, or fail. 5. Plan the kind: - the match's kind, if there is a match; - - otherwise the channel if the name has a live channel link, falling back to the contact kind if that fails and the name has a live contact link; + - otherwise the channel if the name has a live channel link, falling back to the contact kind if that fails, has no relays or needs an app update, and the name has a live contact link (N29); - otherwise the contact kind if the name has a live contact link. It is planned as the typed name, passing the registration. With no kind to plan, answer `CPNameNotConnectable d` with the "nothing" warning. @@ -229,12 +232,13 @@ A missing `graceUntil` drops the second clause of the expiry lines. `otherSimple - **Alert.** `showNameRegistrationAlert` becomes `showNameWarningAlert`, a plain `case` from `NameWarning` to title, message and action (Renew, Register, Re-register, Connect to SimpleX team). It keeps "Open existing chat" when the plan has a chat (1d). - **Flow.** `planAndConnect` shows the alert when the plan has a warning. Otherwise: - - a plan for a chat or own channel (`CAPKnown`, `GLPKnown`, `GLPOwnLink`) with `otherSimplexName` shows 3e from search (N14, N17); from a message, the chat's or own channel's alert carries the other kind's button; + - a plan for a chat or own channel (`CAPKnown`, `GLPKnown`, `GLPOwnLink`) with `otherSimplexName` shows 3e from search (N14, N17); from the new chat sheet, the chat's or own channel's alert carries the other kind's button; - every other plan proceeds as today. There is no `isOwn`, `notConnectable`, `hasLocalChat`, expiry or length logic in either app. -- **3c from a message.** The buttons are Open new chat (Open new channel) and Open existing chat, with no Cancel. Open existing chat opens the plan's `existingChat_`, which core leaves empty for the own address, so it gets Cancel (N20). -- **Name search.** The chat list's "Connect to" row passes the filters, as a pasted link does (N17). The new chat sheet's row passes none, so it behaves as from a message. +- **3c from a message.** The buttons are Open new chat (Open new channel) and Open existing chat, with no Cancel. Open existing chat opens the first of the response's `localChats`, which has no chat for the own address, so it gets Cancel (N20). +- **Name search.** The chat list's "Connect to" row passes the filters, as a pasted link does (N17). The new chat sheet's row passes none, so it behaves as from a message. The search makes one `resolve=never` lookup of the typed text, filters to its `localChats` and shows the row while `offerLookup` holds (N31). +- **Local chats.** Both apps filter to, add to the chat list and open only the response's `localChats`, also for the own channel with a warning. The other kind's button keeps the search filters (N32, N33). - **Types.** Kotlin and Swift get `NameWarning` and `NamePrice` in place of `NameRegistration` and `NamePricing`. The hand-written Swift decoder for `NameRegistration` goes away: `NameWarning` is chat's own type and derives like its neighbours. - **Strings.** The price strings change from "from %s per year" to "%s for %d years". 3e needs a title; its buttons reuse "Join channel %s" / "Connect to %s" and OK. @@ -242,7 +246,7 @@ A missing `graceUntil` drops the second clause of the expiry lines. `otherSimple - **2a, 3c, 4a:** the other kind's button is shown for bare names only. - **3c:** also applies to the own address and channel. From a message, it shows Open new chat and Open existing chat, with no Cancel; for the own address, Cancel (N20). -- **3e (new):** from search, a bare name matches a chat or own channel, and the name also leads to the other kind: "bakery.simplex also leads to channel #bakery", Join channel #bakery, OK. From a message, the chat's or own channel's alert shows the other kind's button instead. +- **3e (new):** from search, a bare name matches a chat or own channel, and the name also leads to the other kind: "bakery.simplex also leads to channel #bakery", Join channel #bakery, OK. From the new chat sheet, the chat's or own channel's alert shows the other kind's button instead. - **Prices:** "$X for 2 years", computed from the registry's price. The amounts on the canvas are examples. - **3a:** "Still leads to your chat, or not found, no valid link, another name, its new link fails, or the request failed" matches §3 and N19. @@ -269,7 +273,7 @@ Decided: | 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, unless the user has a chat, own address or own channel of that kind at its link | | N9 | Own address or channel at another link than the name's | 3c, as for a chat | -| N10 | 3c from a message | Open new chat, and Open existing chat (the plan's `existingChat_`), no Cancel | +| N10 | 3c from a message | Open new chat, and Open existing chat (the first of the response's `localChats`), no Cancel | | N11 | Not registered (reserved for another reason, or too short), 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 | | N15 | The request failed | with a chat: the chat, no alert; with own or nothing: the error alert | @@ -286,6 +290,16 @@ Decided: | N26 | Bot clients | `connLink` is optional in Python and Node; `resolve=allGroups` still parses | | N13 | Own name reserved for community after its registration ended | `NWReservedForCommunity`, 2d's alert | | N14 | What shows 3e | the app, for a chat's or own channel's plan with `otherSimplexName` | +| N27 | Comparing the name's link with a local one | `sameShortLinkContact`: the server's key hash is not compared | +| N28 | Bare name, one kind a fresh chat, nothing local of the other kind | resolved, so the other kind can be offered (3e) | +| N29 | Bare name, nothing local, the channel has no relays or needs an app update | the contact kind is planned, as when the channel fails (N22) | +| N30 | Expired, and the grace date has passed | the dateless variant (N24) | +| N31 | Name search | one `resolve=never` lookup; the list shows its `localChats`; the row shows while `offerLookup` holds, so the own name never hides it | +| N32 | The local chat in the apps | only `localChats`: filtered, added to the list if missing, and opened by Open existing chat; `existingChat_` is removed | +| N33 | The other kind's button from search | keeps the filters, so its 3c shows Cancel (N17) | +| N34 | iOS against Kotlin | the name line in alerts, the own channel's "Your channel" text, and a contact prepared at the name's address opened as in Kotlin | +| N35 | A local chat's name leads to a link that cannot be joined yet (no relays, needs an app update, connecting), or to another chat the user has | the local chat for the first, the other chat for the second | +| N36 | Chats whose name verification changes while planning or preparing | core emits `CEvtContactUpdated` / `CEvtGroupUpdated` for every chat claiming the name of that kind, and for a chat refreshed from its link data | ## 12. Tests @@ -299,7 +313,7 @@ Decided: - a chat with a name reserved for community (3d); - a chat and own with a name not registered (no warning); - a business chat, fresh and not fresh; - - a bare name with a fresh chat (no resolution). + - a bare name with fresh chats of both kinds (no resolution), and with a fresh chat of one kind (resolved). - **Changed:** the freshness tests (§9 of the lookup plan) assert the warning lines; `testPlanKnownNameStale` detects re-resolution by an expired registration. ## 13. Order of work diff --git a/plans/sketches/2026-09-18-names-lookup-flows.excalidraw b/plans/sketches/2026-09-18-names-lookup-flows.excalidraw index 240bbf9408..408df18cd4 100644 --- a/plans/sketches/2026-09-18-names-lookup-flows.excalidraw +++ b/plans/sketches/2026-09-18-names-lookup-flows.excalidraw @@ -15,7 +15,7 @@ {"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":241.56,"height":82.5,"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. A name\nyou have a chat for is resolved at most once a\nday; any other name on every tap, so an owner\ncan check a change. The row hides once the\nname is a chat of every kind.","fontSize":11,"fontFamily":9,"index":"aX","strokeWidth":2,"lineHeight":1.25}, + {"id":"te9023925185","type":"text","x":-17.678157044262946,"y":528.484092339979,"width":241.56,"height":96.25,"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. A name\nyou have a chat for is resolved at most once a\nday; any other name on every tap, so an owner\ncan check a change. The row hides while every\nkind is a chat resolved within a day, never\nfor your own name.","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"}, {"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"}, @@ -35,7 +35,7 @@ {"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":207.38,"height":13.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["message"],"text":"The same lookup and the same alerts.","fontSize":11,"fontFamily":9,"index":"b0d","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 3e\nor 3f, and 3a is today's alert to open the chat.\nThe new chat sheet's Connect to acts the same,\nbut also takes a name without @ or #: 3e is\nthen the chat's alert with 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}, @@ -58,7 +58,7 @@ {"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":41.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; a failed channel is not (N22).","fontSize":11,"fontFamily":9,"index":"b17","strokeWidth":2,"lineHeight":1.25}, + {"id":"te407194617630","type":"text","x":780,"y":531.38,"width":233.77,"height":82.5,"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). A channel shows its\nsubscriber count and Open channel.","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}, @@ -68,7 +68,7 @@ {"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":217.88,"height":41.25,"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, only the first sentence.","fontSize":11,"fontFamily":9,"index":"b1I","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}, @@ -95,10 +95,10 @@ {"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":2102.8,"y":311,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"index":"b1o","strokeWidth":2}, - {"id":"te126855740365","type":"text","x":2110.76,"y":313.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"today","fontSize":10,"fontFamily":9,"index":"b1p","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":237.75,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["notfound"],"text":"As today: too short, a subname, or reserved\nfor another reason.","fontSize":11,"fontFamily":9,"index":"b1r","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}, @@ -126,17 +126,17 @@ {"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":53.51,"height":13.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"As today.","fontSize":11,"fontFamily":9,"index":"b2K","strokeWidth":2,"lineHeight":1.25}, + {"id":"te855635414586","type":"text","x":1360,"y":929.98,"width":249.73,"height":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["failed"],"text":"As today. So are the alerts for no name\nservers, a server without a resolver, a\nnetwork error, and your own name when its\nnew link cannot be fetched.","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":942.8,"y":1281,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"index":"b2O","strokeWidth":2}, - {"id":"te776486861809","type":"text","x":950.76,"y":1283.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["still"],"text":"today","fontSize":10,"fontFamily":9,"index":"b2P","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":41.25,"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 the\nrequest failed.","fontSize":11,"fontFamily":9,"index":"b2R","strokeWidth":2,"lineHeight":1.25}, + {"id":"te103155488047","type":"text","x":780,"y":1395.98,"width":216.66,"height":55.0,"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. 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"}, @@ -149,7 +149,7 @@ {"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":234.44,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["graced"],"text":"Owners and visitors both see that the name\nexpired. Chat is filtered in the list","fontSize":11,"fontFamily":9,"index":"b2c","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"}, @@ -163,7 +163,7 @@ {"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":55.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"From search your chat stays filtered in the list;\nfrom a message Cancel is Open existing chat, except\nfor your own address. Also for your own address or\nchannel.","fontSize":11,"fontFamily":9,"index":"b2r","strokeWidth":2,"lineHeight":1.25}, + {"id":"te377882734292","type":"text","x":1360,"y":1534.78,"width":235.78,"height":68.75,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["moved"],"text":"From search your chat stays filtered in the list;\nfrom a message Cancel is Open existing chat, except\nfor your own address. Also for your own address or\nchannel. A name without @ or # that also leads to\nthe other 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"}, @@ -184,10 +184,10 @@ {"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":942.8,"y":2251,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"index":"b3B","strokeWidth":2}, - {"id":"te10203453787","type":"text","x":950.76,"y":2253.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"today","fontSize":10,"fontFamily":9,"index":"b3C","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":233.77,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["ownshown"],"text":"As today. A name typed without @ or # that also\nleads to a channel offers it too.","fontSize":11,"fontFamily":9,"index":"b3E","strokeWidth":2,"lineHeight":1.25}, + {"id":"te595067966648","type":"text","x":780,"y":2478.08,"width":231.52,"height":41.25,"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 now\nshows this with Join channel, not the channel.","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}, @@ -208,7 +208,7 @@ {"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":237.47,"height":41.25,"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, only the\nfirst sentence.","fontSize":11,"fontFamily":9,"index":"b3c","strokeWidth":2,"lineHeight":1.25}, + {"id":"te999536547743","type":"text","x":1360,"y":2452.08,"width":235.63,"height":41.25,"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.","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}, @@ -225,10 +225,10 @@ {"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":931.61,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"only an actionable alert shows over the filtered chats; from a message it adds Open existing chat; from search, @ or # hides Connect to once the chat is local","fontSize":12,"fontFamily":9,"index":"b47","strokeWidth":2,"lineHeight":1.25}, + {"id":"te845115350481","type":"text","x":774.5454545454545,"y":1112.8872727272728,"width":1012.59,"height":15.0,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"only an actionable alert shows over the filtered chats; from a message it adds Open existing chat; from search, Connect to hides while the chats were resolved within a day","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":197.96,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"your address or channel claims it","fontSize":12,"fontFamily":9,"index":"b49","strokeWidth":2,"lineHeight":1.25}, - {"id":"te921930439761","type":"text","x":60,"y":2582.98,"width":1745.5,"height":27.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Tags compare with today's app: today, changed, new. The two tinted bands split the answers by whether a chat for the name is on the device. Greyed: needs the wallet (simplex-chat#7475), which has not landed. Design document, not screenshots; alerts are drawn in the app's iOS idiom. A lookup returns the chats found on the device and, on Connect to or a\ntapped name, one resolution from the registry: always for a name with no local chat and for your own name, and for a chat only when its cached answer is older than a day or past its expiry. The API is in plans/2026-09-28-name-warnings.md. Registering, owning, recovering and giving names are separate flows.","fontSize":11,"fontFamily":9,"index":"b4A","strokeWidth":2,"lineHeight":1.25}, + {"id":"te921930439761","type":"text","x":60,"y":2582.98,"width":1738.69,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"text":"Tags compare with today's app: today, changed, new. The two tinted bands split the answers by whether a chat for the name is on the device. Register, Renew and Re-register open simplex.domains until the wallet (simplex-chat#7475) lands. Design document, not screenshots: alerts are schematic; iOS shows 4a as an action sheet and pairs short buttons\nside by side. A lookup returns the chats found on the device and, on Connect to or a tapped name, one resolution from the registry: always for a name with no local chat and for your own name, and for a chat only when its cached answer is older than a day or past its expiry. The API is in plans/2026-09-28-name-warnings.md. Registering, owning, recovering\nand 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":300.05,"y":791,"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}, @@ -277,7 +277,7 @@ {"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":"ar3e0000000001","type":"arrow"},{"id":"ar365433195697ch3c","type":"arrow"},{"id":"ar3e0000000001ch3e","type":"arrow"},{"id":"ar174135805467both3f","type":"arrow"}]}, + {"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":"ar3e0000000001","type":"arrow"},{"id":"ar365433195697ch3c","type":"arrow"},{"id":"ar3e0000000001ch3e","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":"relist5","type":"rectangle","x":1878,"y":1300,"width":271.99,"height":125.08,"index":"b50V","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["alsoleads"],"strokeColor":"#868e96"}, {"id":"ellist5","type":"ellipse","x":1882,"y":1307,"width":10,"height":10,"index":"b50W","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["alsoleads"],"strokeColor":"#868e96"}, @@ -293,20 +293,21 @@ {"id":"te3e0000000009","type":"text","x":1940,"y":1449.08,"width":217.53,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleads"],"text":"A name typed without @ or # that also leads\nto a channel or contact you don't have. From\nsearch, over the filtered list.","fontSize":11,"fontFamily":9,"index":"b5A","strokeWidth":2,"lineHeight":1.25}, {"id":"ar3e0000000001","type":"arrow","x":655,"y":1198,"width":1389.995,"height":76,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te3e0000000099","type":"text"}],"points":[[0,0],[1389.995,0],[1389.995,76]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re3e0000000000","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b5K","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945589,0.42407],"focus":0,"gap":0}}, {"id":"te3e0000000099","type":"text","x":1979.2,"y":1252.25,"width":131.59,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"bakery.simplex\nalso leads to the other kind","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar3e0000000001","index":"b5L","strokeWidth":2,"lineHeight":1.25}, - {"id":"relist3ch3c","type":"rectangle","x":1298,"y":1770,"width":271.99,"height":210.8,"index":"b5LV111","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"strokeColor":"#868e96"}, + {"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":210.8,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"index":"b5LV114","strokeWidth":2}, + {"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":"te406902342194ch3c","type":"text","x":1385.86,"y":1888.85,"width":158.34,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"bakery.simplex now leads to a new","fontSize":9,"fontFamily":9,"index":"b5LV117","strokeWidth":2,"lineHeight":1.25}, - {"id":"te662263022972ch3c","type":"text","x":1444.51,"y":1901.87,"width":41.04,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"channel.","fontSize":9,"fontFamily":9,"index":"b5LV118","strokeWidth":2,"lineHeight":1.25}, - {"id":"te869784825642ch3c","type":"text","x":1420.23,"y":1924.31,"width":89.61,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"Open new channel","fontSize":10,"fontFamily":9,"index":"b5LV119","strokeWidth":2,"lineHeight":1.25}, - {"id":"te94030513593ch3c","type":"text","x":1447.05,"y":1950.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":"te406902342194ch3c","type":"text","x":1385.86,"y":1906.85,"width":158.34,"height":11.62,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"bakery.simplex now leads to a new","fontSize":9,"fontFamily":9,"index":"b5LV117","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":"te662263022972ch3c","type":"text","x":1444.51,"y":1919.87,"width":41.04,"height":11.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"channel.","fontSize":9,"fontFamily":9,"index":"b5LV118","strokeWidth":2,"lineHeight":1.25}, + {"id":"te869784825642ch3c","type":"text","x":1420.23,"y":1942.31,"width":89.61,"height":12.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"Open new 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":1988.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":2004.78,"width":218.08,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["movedch3c"],"text":"The same when your chat is a channel: the name\nnow leads to another channel. From a message\nCancel is Open existing chat.","fontSize":11,"fontFamily":9,"index":"b5LV11E","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"}, @@ -321,7 +322,7 @@ {"id":"re3e0000000006ch3e","type":"rectangle","x":2115,"y":1751,"width":35,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleadsch3e"],"index":"b5LV11Q","strokeWidth":2}, {"id":"te3e0000000007ch3e","type":"text","x":2121.95,"y":1753.8,"width":21.1,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleadsch3e"],"text":"new","fontSize":10,"fontFamily":9,"index":"b5LV11R","strokeWidth":2,"lineHeight":1.25}, {"id":"te3e0000000008ch3e","type":"text","x":1940,"y":1902.44,"width":168.69,"height":16.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleadsch3e"],"text":"3e\u2032. Also leads to the contact","fontSize":13,"fontFamily":9,"index":"b5LV11S","strokeWidth":2,"lineHeight":1.25}, - {"id":"te3e0000000009ch3e","type":"text","x":1940,"y":1919.08,"width":209.91,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleadsch3e"],"text":"The same when your chat is a channel: the\nname also leads to a contact you don't\nhave. From search, over the filtered list.","fontSize":11,"fontFamily":9,"index":"b5LV11T","strokeWidth":2,"lineHeight":1.25}, + {"id":"te3e0000000009ch3e","type":"text","x":1940,"y":1919.08,"width":224.57,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["alsoleadsch3e"],"text":"The same when your chat or own channel is a\nchannel: the name also leads to a contact you\ndon't have. From search, over the filtered list.","fontSize":11,"fontFamily":9,"index":"b5LV11T","strokeWidth":2,"lineHeight":1.25}, {"id":"ar3e0000000001ch3e","type":"arrow","x":655,"y":1668,"width":1389.995,"height":76,"strokeColor":"#007aff","fillStyle":"solid","strokeWidth":2,"roughness":0,"boundElements":[{"id":"te3e0000000099ch3e","type":"text"}],"points":[[0,0],[1389.995,0],[1389.995,76]],"endArrowhead":"arrow","elbowed":true,"fixedSegments":null,"endBinding":{"elementId":"re3e0000000000ch3e","focus":0,"gap":26,"fixedPoint":[0.5001,-0.179211]},"index":"b5LV11U","startBinding":{"elementId":"i9ADag2YXvSUyLm-vKlDH","fixedPoint":[39.945589,0.625987],"focus":0,"gap":0}}, {"id":"te3e0000000099ch3e","type":"text","x":1979.2,"y":1722.25,"width":131.59,"height":27.5,"strokeColor":"#007aff","fillStyle":"solid","roughness":0,"text":"bakery.simplex\nalso leads to the other kind","fontSize":11,"fontFamily":9,"textAlign":"center","verticalAlign":"middle","containerId":"ar3e0000000001ch3e","index":"b5LV11V","strokeWidth":2,"lineHeight":1.25}, {"id":"relist1both3f","type":"rectangle","x":718,"y":1770,"width":60,"height":72,"index":"b5LV11W","strokeWidth":1,"fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"strokeColor":"#868e96"}, @@ -333,11 +334,24 @@ {"id":"re1034366287668both3f","type":"rectangle","x":942.8,"y":1751,"width":47.2,"height":15,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"index":"b5LV11c","strokeWidth":2}, {"id":"te776486861809both3f","type":"text","x":950.76,"y":1753.8,"width":31.27,"height":12.5,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"text":"today","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":222.75,"height":41.25,"strokeColor":"#1e1e1e","fillStyle":"solid","roughness":0,"groupIds":["bothkinds"],"text":"Search lists both chats and hides Connect to,\nso there is no alert. A name in a message has\n@ or #, so it is one of 3a to 3d.","fontSize":11,"fontFamily":9,"index":"b5LV11f","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 and, while both were\nresolved within a day, hides Connect to. A name\nin a message has @ or #, so it is one of 3a to\n3d, or 3c\u2032.","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":"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":55.0,"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.","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":2222.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/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 21204789de..95318888e1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -885,7 +885,7 @@ data ChatResponse | CRInvitation {user :: User, connLinkInvitation :: CreatedLinkInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRConnectionUserChanged {user :: User, fromConnection :: PendingContactConnection, toConnection :: PendingContactConnection, newUser :: User} - | CRConnectionPlan {user :: User, connLink :: Maybe ACreatedConnLink, planSimplexName :: Maybe SimplexNameInfo, otherSimplexName :: Maybe SimplexNameInfo, connectionPlan :: ConnectionPlan} + | CRConnectionPlan {user :: User, connLink :: Maybe ACreatedConnLink, planSimplexName :: Maybe SimplexNameInfo, otherSimplexName :: Maybe SimplexNameInfo, connectionPlan :: ConnectionPlan, localChats :: [AChatInfo], offerLookup :: Bool} | CRNewPreparedChat {user :: User, chat :: AChat} | CRContactUserChanged {user :: User, fromContact :: Contact, newUser :: User, toContact :: Contact} | CRGroupUserChanged {user :: User, fromGroup :: GroupInfo, newUser :: User, toGroup :: GroupInfo} @@ -1180,7 +1180,7 @@ data InvitationLinkPlan deriving (Show) data ContactAddressPlan - = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} + = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool} | CAPOwnLink | CAPConnectingConfirmReconnect | CAPConnectingProhibit {contact :: Contact} @@ -1189,7 +1189,7 @@ data ContactAddressPlan deriving (Show) data GroupLinkPlan - = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} + = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool} | GLPOwnLink {groupInfo :: GroupInfo} | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3451091c08..59dbbe621b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -40,7 +40,7 @@ import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Functor.Identity (Identity (..), runIdentity) import Data.Int (Int64) -import Data.List (dropWhileEnd, find, foldl', isSuffixOf, partition, sortOn, zipWith4) +import Data.List (dropWhileEnd, find, foldl', isSuffixOf, nubBy, partition, sortOn, zipWith4) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -2177,7 +2177,8 @@ processChatCommand cxt nm = \case pure conn' APIConnectPlan userId (Just ct) resolveMode linkOwnerSig_ -> withUserId userId $ \user -> do (ccLink, planSimplexName, otherSimplexName, plan) <- connectPlan user ct resolveMode linkOwnerSig_ Nothing - pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan + (localChats, offerLookup) <- planLocalChats user ct plan + pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan localChats offerLookup APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do let ContactShortLinkData {profile, message, business} = contactSLinkData @@ -2192,6 +2193,7 @@ processChatCommand cxt nm = \case groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing domain_ + forM_ domain_ $ nameChatsUpdated user NTContact 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 @@ -2205,6 +2207,7 @@ processChatCommand cxt nm = \case pure $ CRNewPreparedChat user $ AChat SCTGroup chat ACCL _ (CCLink cReq _) -> do ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId (True <$ domain_) + forM_ domain_ $ nameChatsUpdated user NTContact 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,6 +2224,7 @@ processChatCommand cxt nm = \case domain_ = if verifiedDomain == (claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) then verifiedDomain else Nothing welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId (gInfo, hostMember_) <- preparedGroupFromLink user ccLink direct groupSLinkData welcomeSharedMsgId domain_ + forM_ domain_ $ nameChatsUpdated user NTPublicGroup 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 @@ -2407,7 +2411,7 @@ processChatCommand cxt nm = \case ACTarget m (CTFullContact cReq) -> con m cReq ACTarget m (CTInv (CLFull cReq)) -> con m cReq _ -> throwError e - connectWithPlan user incognito ccLink planSimplexName otherSimplexName plan + connectWithPlan user incognito ccLink planSimplexName otherSimplexName plan =<< planLocalChats user ct plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIVerifyContactDomain contactId -> withUser $ \user -> do ct@Contact {profile = LocalProfile {contactDomain}, preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId @@ -2415,6 +2419,7 @@ processChatCommand cxt nm = \case 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 Nothing) verified + when (verified == Just True) $ nameChatsUpdated user NTContact $ claimDomain domain pure $ CRContactDomainVerified user ct' reason APIVerifyGroupDomain groupId -> withUser $ \user -> do g@GroupInfo {groupProfile = GroupProfile {publicGroup}} <- withFastStore $ \db -> getGroupInfo db cxt user groupId @@ -2429,6 +2434,7 @@ processChatCommand cxt nm = \case 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 Nothing + when verified $ nameChatsUpdated user NTPublicGroup $ claimDomain claim 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 @@ -2446,8 +2452,8 @@ 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 False Nothing) Nothing) - connectWithPlan user incognito (Just (ACCL SCMContact (CCLink adminContactReq Nothing))) Nothing Nothing plan + plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing False) Nothing) + connectWithPlan user incognito (Just (ACCL SCMContact (CCLink adminContactReq Nothing))) Nothing Nothing plan ([], False) DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing APIListContacts userId -> withUserId userId $ \user -> @@ -4113,6 +4119,7 @@ processChatCommand cxt nm = \case gInfo' <- withStore $ \db -> do g <- updateGroupProfile db user gInfo p' if domainVerified then liftIO $ setGroupDomainVerified db user g True Nothing else pure g + when domainVerified $ forM_ (publicGroup p' >>= publicGroupAccess >>= groupDomainClaim) $ nameChatsUpdated user NTPublicGroup . claimDomain msg <- case businessChat of Just BusinessChatInfo {businessId} -> do ms <- withStore' $ \db -> getGroupMembers db cxt user gInfo' @@ -4418,11 +4425,12 @@ processChatCommand cxt nm = \case pure (Just (ACCL SCMInvitation (CCLink cReq sLnk_)), Nothing, Nothing, plan) connectPlan user (ACTarget SCMContact ct) resolveMode sig_ nameReg_ = case ct of CTDomain d -> do - channel_ <- knownGroupPlans $ CTName channelName - contact_ <- knownContactPlans $ CTName contactName + channel_ <- knownGroupPlans user $ CTName channelName + contact_ <- knownContactPlans user $ CTName contactName let match_ = (channelName,) <$> channel_ <|> (contactName,) <$> contact_ + bothLocal = isJust channel_ && isJust contact_ case match_ of - Just (ni, ((l, p), fresh)) | resolveMode == PRMNever || (resolveMode == PRMUnknown && fresh) -> pure (Just l, Just ni, Nothing, p) + Just (ni, ((l, p), fresh)) | resolveMode == PRMNever || (resolveMode == PRMUnknown && fresh && bothLocal) -> pure (Just l, Just ni, Nothing, p) Nothing | resolveMode == PRMNever -> throwChatError CENotResolvedLocally _ -> tryAllErrors (resolveNameRegistration user nm d) >>= \case @@ -4438,12 +4446,19 @@ processChatCommand cxt nm = \case NTPublicGroup -> newName contactName contact_ NTContact -> newName channelName channel_ newName ni known_ = case linkOrWarning ni of - Right l' | (knownLinkOf . fst . fst =<< known_) /= Just l' -> Just ni + Right l' | not (any (isLinkOf l' . fst . fst) known_) -> Just ni _ -> Nothing + contactPlanOr fallback = namePlanWith Nothing contactName `catchAllErrors` \_ -> fallback + unjoinable (_, _, _, p) = case p of + CPGroupLink GLPNoRelays {} _ -> True + CPGroupLink GLPUpdateRequired {} _ -> True + _ -> False case match_ of Just (ni, _) -> namePlan ni Nothing -> case (linkOrWarning channelName, linkOrWarning contactName) of - (Right _, Right _) -> namePlan channelName `catchAllErrors` \e -> (namePlanWith Nothing contactName `catchAllErrors` \_ -> throwError e) + (Right _, Right _) -> + (namePlan channelName >>= \r -> if unjoinable r then contactPlanOr (pure r) else pure r) + `catchAllErrors` \e -> contactPlanOr (throwError e) (Right _, Left _) -> namePlan channelName (Left _, Right _) -> namePlan contactName (Left w, Left _) -> pure (Nothing, Nothing, Nothing, CPNameNotConnectable d w) @@ -4456,7 +4471,7 @@ processChatCommand cxt nm = \case pure (Just (ACCL SCMContact $ CCLink cReq Nothing), Nothing, Nothing, plan) CTShortContact nl -> (\(l, p) -> (l, simplexName_, Nothing, p)) <$> case ctType of - CCTContact -> knownContactPlans nl' >>= shortLinkPlan confirmKnown resolvedPlan + CCTContact -> knownContactPlans user nl' >>= shortLinkPlan confirmKnown resolvedPlan where confirmKnown expiresAt _ r@(_, p) = r <$ when (isJust simplexName_) (setKnownVerified expiresAt p) resolvedPlan expiresAt l' = do @@ -4498,10 +4513,12 @@ processChatCommand cxt nm = \case CTName SimplexNameInfo {nameType = NTContact} -> CCTContact CTName SimplexNameInfo {nameType = NTPublicGroup} -> CCTChannel con l' cReq = ACCL SCMContact $ CCLink cReq (Just l') - setKnownVerified expiresAt = \case - CPContactAddress (CAPKnown ct') _ -> void $ withFastStore' $ \db -> setContactDomainVerified db user ct' True expiresAt - CPGroupLink (GLPKnown g _ _ _) _ -> void $ withFastStore' $ \db -> setGroupDomainVerified db user g True expiresAt - _ -> pure () + setKnownVerified expiresAt p = do + verified <- case p of + CPContactAddress (CAPKnown ct') _ -> True <$ withFastStore' (\db -> setContactDomainVerified db user ct' True expiresAt) + CPGroupLink (GLPKnown g _ _ _) _ -> True <$ withFastStore' (\db -> setGroupDomainVerified db user g True expiresAt) + _ -> pure False + when verified $ forM_ simplexName_ $ \SimplexNameInfo {nameType, nameDomain} -> nameChatsUpdated user nameType nameDomain shortLinkPlan :: (Maybe UTCTime -> ShortLinkContact -> (ACreatedConnLink, ConnectionPlan) -> CM (ACreatedConnLink, ConnectionPlan)) -> (Maybe UTCTime -> ShortLinkContact -> CM (ACreatedConnLink, ConnectionPlan)) -> @@ -4537,15 +4554,15 @@ processChatCommand cxt nm = \case Just r@(_, p) | knownChat p -> pure r _ -> throwError e linkPlan expiresAt l' = case known_ of - Just r@(l, p) - | knownLinkOf l == Just l' -> confirmKnown expiresAt l' r + Just r@(l, _) + | isLinkOf l' l -> confirmKnown expiresAt l' r | otherwise -> - (second (setAddressChanged p) <$> resolvedPlan expiresAt l') `catchAllErrors` \case + ((\(l'', p) -> maybe r (l'',) $ setAddressChanged p) <$> resolvedPlan expiresAt l') `catchAllErrors` \case ChatError (CESimplexDomainNotReady _ SDEUnknownDomain) -> pure r e -> throwError e Nothing -> resolvedPlan expiresAt l' groupShortLinkPlan :: CM (Maybe ACreatedConnLink, ConnectionPlan) - groupShortLinkPlan = knownGroupPlans nl' >>= shortLinkPlan confirmKnown resolvedGroupPlan + groupShortLinkPlan = knownGroupPlans user nl' >>= shortLinkPlan confirmKnown resolvedGroupPlan where confirmKnown expiresAt l' = \case (_, CPGroupLink (GLPKnown g _ _ _) _) -> resolveKnownGroup expiresAt l' g @@ -4578,7 +4595,7 @@ processChatCommand cxt nm = \case _ -> pure plan0 forM_ planDomain $ \nameDomain -> let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of - CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _ _ _) _ -> Just groupProfile + 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 @@ -4598,54 +4615,79 @@ processChatCommand cxt nm = \case _ -> pure (g, False) pure (con l' cReq, CPGroupLink (GLPKnown g' updated ov (ListDef glOwners)) Nothing) where - knownContactPlans :: ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) - knownContactPlans nl' = withFastStore $ \db -> - liftIO (getUserContactLinkViaTarget db user nl') >>= \case - Just UserContactLink {connLinkContact} -> pure $ Just ((ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink Nothing), False) - Nothing -> - getContactToConnect db cxt user nl' >>= \case - Just (ccl, ct') - | contactDeleted ct' -> pure Nothing - | otherwise -> do - fresh <- liftIO $ resolvedRecently =<< getContactDomainResolution db user ct' - pure $ Just ((ACCL SCMContact ccl, CPContactAddress (CAPKnown ct') Nothing), fresh) - Nothing -> gPlan db =<< getGroupToConnect db cxt user nl' - knownGroupPlans :: ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) - knownGroupPlans nl' = withFastStore $ \db -> - liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case - Just (ccl, g) -> pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g) Nothing), False) - Nothing -> gPlan db =<< getGroupToConnect db cxt user nl' - gPlan db = \case - Just (ccl, g) | not (memberRemoved (membership g)) -> do - fresh <- liftIO $ resolvedRecently =<< getGroupDomainResolution db user g - pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])) Nothing), fresh) - _ -> pure Nothing - resolvedRecently :: Maybe (UTCTime, Maybe UTCTime) -> IO Bool - resolvedRecently = \case - Just (resolvedAt, expiresAt_) -> do - now <- getCurrentTime - pure $ diffUTCTime now resolvedAt < nominalDay && maybe True (now <) expiresAt_ - Nothing -> pure False knownChat = \case CPContactAddress (CAPKnown _) _ -> True CPGroupLink GLPKnown {} _ -> True _ -> False - knownLinkOf :: ACreatedConnLink -> Maybe ShortLinkContact - knownLinkOf = \case - ACCL SCMContact (CCLink _ sl_) -> sl_ + isLinkOf :: ShortLinkContact -> ACreatedConnLink -> Bool + isLinkOf l' = \case + ACCL SCMContact (CCLink _ (Just sl)) -> sameShortLinkContact sl l' + _ -> False + knownContactPlans :: User -> ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) + knownContactPlans user nl' = withFastStore $ \db -> + liftIO (getUserContactLinkViaTarget db user nl') >>= \case + Just UserContactLink {connLinkContact} -> pure $ Just ((ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink Nothing), False) + Nothing -> + getContactToConnect db cxt user nl' >>= \case + Just (ccl, ct') + | contactDeleted ct' -> pure Nothing + | otherwise -> do + fresh <- liftIO $ resolvedRecently =<< getContactDomainResolution db user ct' + pure $ Just ((ACCL SCMContact ccl, CPContactAddress (CAPKnown ct') Nothing), fresh) + Nothing -> gPlan user db =<< getGroupToConnect db cxt user nl' + knownGroupPlans :: User -> ContactNameOrLink -> CM (Maybe ((ACreatedConnLink, ConnectionPlan), Bool)) + knownGroupPlans user nl' = withFastStore $ \db -> + liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case + Just (ccl, g) -> pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g) Nothing), False) + Nothing -> gPlan user db =<< getGroupToConnect db cxt user nl' + gPlan user db = \case + Just (ccl, g) | not (memberRemoved (membership g)) -> do + fresh <- liftIO $ resolvedRecently =<< getGroupDomainResolution db user g + pure $ Just ((ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])) Nothing), fresh) + _ -> pure Nothing + resolvedRecently :: Maybe (UTCTime, Maybe UTCTime) -> IO Bool + resolvedRecently = \case + Just (resolvedAt, expiresAt_) -> do + now <- getCurrentTime + pure $ diffUTCTime now resolvedAt < nominalDay && maybe True (now <) expiresAt_ + Nothing -> pure False + -- the chats a plan is about, the planned one first, and whether looking the name up can show more than them + planLocalChats :: User -> AConnectTarget -> ConnectionPlan -> CM ([AChatInfo], Bool) + planLocalChats user ct plan = do + found <- nameLookups + let chats = nubBy (\(AChatInfo _ c) (AChatInfo _ c') -> chatInfoToRef c == chatInfoToRef c') $ mapMaybe planChat $ plan : map (snd . fst) (catMaybes found) + pure (chats, not $ all (maybe False snd) found) + where + nameLookups :: CM [Maybe ((ACreatedConnLink, ConnectionPlan), Bool)] + nameLookups = case ct of + ACTarget SCMContact (CTDomain d) -> sequence [knownGroupPlans user $ CTName $ SimplexNameInfo NTPublicGroup d, knownContactPlans user $ CTName $ SimplexNameInfo NTContact d] + ACTarget SCMContact (CTShortContact nl@(CTName SimplexNameInfo {nameType = NTContact})) -> (: []) <$> knownContactPlans user nl + ACTarget SCMContact (CTShortContact nl@(CTName SimplexNameInfo {nameType = NTPublicGroup})) -> (: []) <$> knownGroupPlans user nl + _ -> pure [] + planChat = \case + CPInvitationLink (ILPConnecting (Just ct')) -> directChat ct' + CPInvitationLink (ILPKnown ct') -> directChat ct' + CPContactAddress (CAPConnectingProhibit ct') _ -> directChat ct' + CPContactAddress (CAPKnown ct') _ -> directChat ct' + CPContactAddress (CAPContactViaAddress ct') _ -> directChat ct' + CPGroupLink (GLPOwnLink g) _ -> groupChat g + CPGroupLink (GLPConnectingProhibit (Just g)) _ -> groupChat g + CPGroupLink (GLPKnown g _ _ _) _ -> groupChat g _ -> Nothing - connectWithPlan :: User -> IncognitoEnabled -> Maybe ACreatedConnLink -> Maybe SimplexNameInfo -> Maybe SimplexNameInfo -> ConnectionPlan -> CM ChatResponse - connectWithPlan user@User {userId} incognito ccLink_ planSimplexName otherSimplexName plan + directChat = Just . AChatInfo SCTDirect . DirectChat + groupChat g = Just $ AChatInfo SCTGroup $ GroupChat g Nothing + connectWithPlan :: User -> IncognitoEnabled -> Maybe ACreatedConnLink -> Maybe SimplexNameInfo -> Maybe SimplexNameInfo -> ConnectionPlan -> ([AChatInfo], Bool) -> CM ChatResponse + connectWithPlan user@User {userId} incognito ccLink_ planSimplexName otherSimplexName plan (localChats, offerLookup) | Just ccLink <- ccLink_, connectionPlanProceed plan = do case plan of CPError e -> eToView e; _ -> pure () case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) _ -> processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId - CPContactAddress (CAPOk (Just sld) _ _ _) _ | isJust vName -> connectContactViaName ccLink sld - CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _ _ _) _ + CPContactAddress (CAPOk (Just sld) _ _) _ | isJust vName -> connectContactViaName ccLink 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 + | otherwise = pure $ CRConnectionPlan user ccLink_ planSimplexName otherSimplexName plan localChats offerLookup where vName = nameDomain <$> planSimplexName joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> CM ChatResponse @@ -4709,13 +4751,13 @@ processChatCommand cxt nm = \case Nothing -> withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case Just ct | not (contactDeleted ct) -> plan $ CAPContactViaAddress ct - _ -> plan $ CAPOk cld ov False Nothing + _ -> plan $ CAPOk cld ov False Just (RcvDirectMsgConnection Connection {connStatus} Nothing) - | connStatus == ConnPrepared -> plan $ CAPOk cld ov False Nothing + | connStatus == ConnPrepared -> plan $ CAPOk cld ov False | otherwise -> plan CAPConnectingConfirmReconnect Just (RcvDirectMsgConnection _ (Just ct)) | not (contactReady ct) && contactActive ct -> plan $ CAPConnectingProhibit ct - | contactDeleted ct -> plan $ CAPOk cld ov False Nothing + | contactDeleted ct -> plan $ CAPOk cld ov False | otherwise -> plan $ CAPKnown ct -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Nothing [] @@ -4731,12 +4773,12 @@ processChatCommand cxt nm = \case connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes case (gInfo_, connEnt_) of - (Nothing, Nothing) -> plan $ GLPOk linkInfo gld ov False Nothing + (Nothing, Nothing) -> plan $ GLPOk linkInfo gld ov False -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> plan $ GLPConnectingConfirmReconnect (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> plan $ GLPConnectingProhibit gInfo_ - | otherwise -> plan $ GLPOk linkInfo gld ov False Nothing + | otherwise -> plan $ GLPOk linkInfo gld ov False (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" (Just gInfo, _) -> groupPlan gInfo linkInfo gld ov glOwners groupPlan :: GroupInfo -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> Maybe OwnerVerification -> [GroupLinkOwner] -> CM ConnectionPlan @@ -4745,7 +4787,7 @@ processChatCommand cxt nm = \case | not (memberActive membership) && not (memberRemoved membership) = plan $ GLPConnectingProhibit $ Just gInfo | memberActive membership = plan $ GLPKnown gInfo False ov (ListDef glOwners) - | otherwise = plan $ GLPOk linkInfo gld ov False Nothing + | otherwise = plan $ GLPOk linkInfo gld ov False where plan p = pure $ CPGroupLink p Nothing contactCReqSchemas :: ConnReqContact -> (ConnReqContact, ConnReqContact) @@ -5158,7 +5200,7 @@ nameExpiresAt = \case nameLinkOrWarning :: SystemSeconds -> SimplexNameInfo -> NameRegistration -> Either NameWarning ShortLinkContact nameLinkOrWarning now SimplexNameInfo {nameType, nameDomain = SimplexDomain {domain}} = \case NRRegistered {expires = Just expires, graceUntil} - | expires < now -> Left $ NWExpired (roundedToUTCTime expires) (roundedToUTCTime <$> graceUntil) + | expires < now -> Left $ NWExpired (roundedToUTCTime expires) (roundedToUTCTime <$> mfilter (now <=) graceUntil) NRRegistered {nameRecord = NameRecord {nrSimplexContact, nrSimplexChannel}} -> maybe (Left NWNoValidLink) Right $ case nameType of NTContact -> firstNameLink CCTContact nrSimplexContact @@ -5192,18 +5234,17 @@ setNameWarning w = \case NWReservedForCommunity -> Just w _ -> Nothing -setAddressChanged :: ConnectionPlan -> ConnectionPlan -> ConnectionPlan -setAddressChanged known = \case - CPContactAddress (CAPOk cld ov _ _) w_ -> CPContactAddress (CAPOk cld ov True existingChat_) w_ - CPGroupLink (GLPOk li gld ov _ _) w_ -> CPGroupLink (GLPOk li gld ov True existingChat_) w_ - p -> p - where - existingChat_ = case known of - CPContactAddress (CAPKnown ct) _ -> Just $ AChatInfo SCTDirect $ DirectChat ct - CPGroupLink (GLPKnown g _ _ _) _ -> groupChat g - CPGroupLink (GLPOwnLink g) _ -> groupChat g - _ -> Nothing - groupChat g = Just $ AChatInfo SCTGroup $ GroupChat g Nothing +setAddressChanged :: ConnectionPlan -> Maybe ConnectionPlan +setAddressChanged = \case + CPContactAddress (CAPOk cld ov _) w_ -> Just $ CPContactAddress (CAPOk cld ov True) w_ + CPGroupLink (GLPOk li gld ov _) w_ -> Just $ CPGroupLink (GLPOk li gld ov True) w_ + CPContactAddress CAPConnectingConfirmReconnect _ -> Nothing + CPContactAddress CAPConnectingProhibit {} _ -> Nothing + CPGroupLink GLPConnectingConfirmReconnect _ -> Nothing + CPGroupLink GLPConnectingProhibit {} _ -> Nothing + CPGroupLink GLPNoRelays {} _ -> Nothing + CPGroupLink GLPUpdateRequired {} _ -> Nothing + p -> Just p 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 diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d9e9961778..6d370cd30c 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1619,14 +1619,16 @@ updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> Maybe Simp updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData} resolvedDomain_ expiresAt | profileChanged || countChanged || verifyResolved = do cxt <- chatStoreCxt - withStore $ \db -> do + g'' <- 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 expiresAt else pure g' - pure (g'', profileChanged) + if verifyResolved then liftIO $ setGroupDomainVerified db user g' True expiresAt else pure g' + toView $ CEvtGroupUpdated user gInfo g'' Nothing Nothing + when verifyResolved $ forM_ newClaim $ nameChatsUpdated user NTPublicGroup + pure (g'', profileChanged) | otherwise = pure (gInfo, False) where profileChanged = p /= groupProfile @@ -1641,15 +1643,25 @@ updateContactFromLinkData :: User -> Contact -> Profile -> Maybe UTCTime -> CM C updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} linkProfile@Profile {contactDomain = newClaim} expiresAt | profileChanged || verifyChanged = do cxt <- chatStoreCxt - withFastStore $ \db -> do + ct'' <- withFastStore $ \db -> do ct' <- updateContactProfile db cxt user ct linkProfile if verifyChanged then liftIO $ setContactDomainVerified db user ct' True expiresAt else pure ct' + toView $ CEvtContactUpdated user ct ct'' + when verifyChanged $ forM_ newClaim $ nameChatsUpdated user NTContact . claimDomain + pure ct'' | otherwise = pure ct where profileChanged = fromLocalProfile profile /= linkProfile claimChanged = (claimDomain <$> prevClaim) /= (claimDomain <$> newClaim) verifyChanged = contactDomainVerified /= Just True || claimChanged +nameChatsUpdated :: User -> SimplexNameType -> SimplexDomain -> CM () +nameChatsUpdated user nameType domain = do + cxt <- chatStoreCxt + (cts, gs) <- withStore' $ \db -> getNameChats db cxt user nameType domain + forM_ cts $ \ct -> toView $ CEvtContactUpdated user ct ct + forM_ gs $ \g -> toView $ CEvtGroupUpdated user g g Nothing Nothing + -- TODO [relays] owner: set owners on updating link data (multi-owner) groupLinkData :: GroupInfoKeys -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) groupLinkData (GIK gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {publicMemberCount}, membership = GroupMember {memberId}} gks) GroupLink {groupLinkId} groupRelays = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index eec0b8298e..88425e5c1f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -47,6 +47,7 @@ module Simplex.Chat.Store.Groups getGroupInfoByGroupLinkHash, updateGroupProfile, setGroupDomainVerified, + getNameChats, getGroupDomainResolution, updateGroupPreferences, updateGroupProfileFromMember, @@ -2755,6 +2756,20 @@ setGroupDomainVerified db User {userId} g@GroupInfo {groupId, businessChat} veri (BI verified, currentTs, expiresAt, userId, groupId) pure g {groupDomainVerified = Just verified} +getNameChats :: DB.Connection -> StoreCxt -> User -> SimplexNameType -> SimplexDomain -> IO ([Contact], [GroupInfo]) +getNameChats db cxt user@User {userId} nameType domain = do + cts <- case nameType of + NTContact -> ids "SELECT ct.contact_id FROM contacts ct JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id WHERE ct.user_id = ? AND cp.contact_domain = ? AND ct.deleted = 0" >>= fmap rights . mapM (runExceptT . getContact db cxt user) + NTPublicGroup -> pure [] + gs <- ids (groupsQuery <> businessCond) >>= fmap rights . mapM (runExceptT . getGroupInfo db cxt user) + pure (cts, gs) + where + ids q = map fromOnly <$> DB.query db q (userId, domain) + groupsQuery = "SELECT g.group_id FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? AND gp.group_domain = ?" + businessCond = case nameType of + NTContact -> " AND g.business_chat IS NOT NULL" + NTPublicGroup -> " AND g.business_chat IS NULL" + getGroupDomainResolution :: DB.Connection -> User -> GroupInfo -> IO (Maybe (UTCTime, Maybe UTCTime)) getGroupDomainResolution db User {userId} GroupInfo {groupId} = maybeFirstRow id $ diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 54fb46d1d2..a2bb747784 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -217,7 +217,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 planSimplexName otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName <> viewNameWarning planSimplexName connectionPlan + CRConnectionPlan u connLink planSimplexName otherSimplexName connectionPlan _ _ -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName <> viewNameWarning planSimplexName connectionPlan CRNewPreparedChat u (AChat _ (Chat cInfo _ _)) -> ttyUser u $ case cInfo of DirectChat ct -> [ttyContact' ct <> ": contact is prepared"] GroupChat g _ -> [ttyGroup' g <> ": group is prepared"] @@ -2278,7 +2278,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("invitation link: " <>) CPContactAddress cap _ -> case cap of - CAPOk contactSLinkData ov addressChanged _ -> [addrOrBiz contactSLinkData ("ok to connect" <> (if addressChanged then ", address changed" else ""))] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] + CAPOk contactSLinkData ov addressChanged -> [addrOrBiz contactSLinkData ("ok to connect" <> (if addressChanged then ", address changed" else ""))] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView] CAPOwnLink -> [ctAddr "own address"] CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] @@ -2296,7 +2296,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("contact address: " <>) CPGroupLink glp _ -> case glp of - GLPOk groupSLinkInfo_ groupSLinkData ov addressChanged _ -> + GLPOk groupSLinkInfo_ groupSLinkData ov addressChanged -> let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_ in [grpLink $ (if direct then "ok to connect directly" else "ok to connect via relays") <> (if addressChanged then ", address changed" else "")] <> viewSigVerification ov diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index e9727f7987..3718893f5e 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -10,10 +10,12 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel', prepareChannel1Relay) import ChatTests.Utils +import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad.Reader (runReaderT) import Data.ByteString (ByteString) import Data.Int (Int64) +import Data.Maybe (listToMaybe, mapMaybe) import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -49,11 +51,16 @@ chatNamesTests = do 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, name moved to another known chat" testPlanKnownNameMovedToKnownChat it "known chat, name now available" testPlanKnownNameAvailable it "known chat and own name, name without link or reserved, stored as resolved" testPlanKnownNameReserved it "known chat and own name, the request failed" testPlanKnownNameResolverFailed it "known chat, 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, the contact is planned" testPlanNameChannelNoRelays + it "known chats, the name's link differs only in its key hash" testPlanNameLinkKeyHash + it "local chats, and whether a lookup can show more" testPlanLocalChats it "known chat, resolved over a day ago or past expiry" testPlanKnownNameStale it "no local chat, resolved on every call" testPlanNameResolvedEveryCall it "own name, expired" testPlanOwnNameExpired @@ -283,6 +290,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> bob <## "group link: known group #team" bob <## "SimpleX name: #team (verified)" bob <## "use #team to send messages" + bob <## "You can also connect to @team.simplex in direct chat" setGroupNamesStale bob bob ##> "/_connect plan 1 team.simplex" knownTeamPlan bob @@ -291,6 +299,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> knownTeamPlan bob bob ##> "/_connect plan 1 team.simplex" knownTeamPlan bob + bob <## "You can also connect to @team.simplex in direct chat" bob ##> "/_connect plan 1 team.simplex resolve=all" knownTeamPlan bob bob <## "You can also connect to @team.simplex in direct chat" @@ -301,13 +310,11 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> bob <## "You can also connect to @team.simplex in direct chat" bob ##> "/_connect plan 1 team.simplex" knownTeamPlan bob + bob <## "You can also connect to @team.simplex in direct chat" + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownTeamPlan bob where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) - knownTeamPlan :: HasCallStack => TestCC -> IO () - knownTeamPlan cc = do - cc <## "group link: known group #team" - cc <## "SimpleX name: #team (verified)" - cc <## "use #team to send messages" -- The bare name "acme.simplex" resolves to both a channel and a direct contact. The channel is tried -- first but its group profile does not claim the domain, so the channel side of the plan fails; the @@ -419,14 +426,22 @@ planExistingChat TestCC {chatController = cc} cmd = do cmd' <- either fail pure $ parseChatCommand cmd r <- execChatCommand' cmd' 0 `runReaderT` cc case r of - Right CRConnectionPlan {connectionPlan = CPContactAddress CAPOk {existingChat_} _} -> pure $ chatName =<< existingChat_ - Right CRConnectionPlan {connectionPlan = CPGroupLink GLPOk {existingChat_} _} -> pure $ chatName =<< existingChat_ + Right CRConnectionPlan {connectionPlan = CPContactAddress CAPOk {} _, localChats} -> pure $ chatName =<< listToMaybe localChats + Right CRConnectionPlan {connectionPlan = CPGroupLink GLPOk {} _, localChats} -> pure $ chatName =<< listToMaybe localChats _ -> fail $ "unexpected response: " <> show r - where - chatName :: AChatInfo -> Maybe String - chatName (AChatInfo _ (DirectChat Contact {localDisplayName})) = Just $ T.unpack localDisplayName - chatName (AChatInfo _ (GroupChat GroupInfo {localDisplayName} _)) = Just $ T.unpack localDisplayName - chatName _ = Nothing + +planLocal :: TestCC -> ByteString -> IO ([String], Bool) +planLocal TestCC {chatController = cc} cmd = do + cmd' <- either fail pure $ parseChatCommand cmd + r <- execChatCommand' cmd' 0 `runReaderT` cc + case r of + Right CRConnectionPlan {localChats, offerLookup} -> pure (mapMaybe chatName localChats, offerLookup) + _ -> fail $ "unexpected response: " <> show r + +chatName :: AChatInfo -> Maybe String +chatName (AChatInfo _ (DirectChat Contact {localDisplayName})) = Just $ T.unpack localDisplayName +chatName (AChatInfo _ (GroupChat GroupInfo {localDisplayName} _)) = Just $ T.unpack localDisplayName +chatName _ = Nothing connectBobByName :: HasCallStack => TestCC -> TestCC -> IO () connectBobByName alice bob = do @@ -640,6 +655,31 @@ testPlanKnownNameNewChatOpened ps = withSmpServerAndNames $ \reg -> bob <## "contact address: known prepared contact cath" bob <## "SimpleX name: @alice.simplex (verified)" +testPlanKnownNameMovedToKnownChat :: HasCallStack => TestParams -> IO () +testPlanKnownNameMovedToKnownChat ps = withSmpServerAndNames $ \reg -> + testChat3 aliceProfile bobProfile cathProfile (test reg) ps + where + test reg alice bob cath = do + mapM_ enableNamesRole [alice, bob, cath] + _ <- setAliceName reg alice + connectBobByName alice bob + (cathLink, _) <- setAliceName reg cath + bob ##> ("/c " <> cathLink) + bob <## "connection request sent!" + cath <## "bob (Bob) wants to connect to you!" + cath <## "to accept: /ac bob" + cath <## "to reject: /rc bob (the sender will NOT be notified)" + 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") + setContactNamesStale bob + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "contact address: known contact cath" + bob <## "SimpleX name: @alice.simplex (verified)" + bob <## "use @cath to send messages" + testPlanNameResolverFailed :: HasCallStack => TestParams -> IO () testPlanNameResolverFailed = withAliceName $ \reg _r _alice bob -> do failNameResolution reg brokenName @@ -702,6 +742,143 @@ testPlanChannelNameMoved ps = withSmpServerAndNames $ \reg -> where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) +testPlanChannelNameMovedNoRelays :: HasCallStack => TestParams -> IO () +testPlanChannelNameMovedNoRelays ps = withSmpServerAndNames $ \reg -> + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] + (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath + registerName reg teamName (channelNameRecord "team.simplex" (T.pack shortLink)) + alice ##> "/public group access #team domain=team.simplex" + alice <## "updated public group access: domain=team.simplex" + cath <## "alice updated group #team: (signed)" + cath <## "updated public group access: domain=team.simplex" + memberJoinChannel "team" [cath] [alice] shortLink fullLink bob + bob ##> "/_verify domain #1" + bob <## "SimpleX name #team verified" + (shortLink2, _) <- prepareChannel' 2 "team2" alice cath + registerName reg teamName (channelNameRecord "team.simplex" (T.pack shortLink2)) + alice ##> "/public group access #team2 domain=team.simplex" + alice <## "updated public group access: domain=team.simplex" + cath <## "alice_1 updated group #team2: (signed)" + cath <## "updated public group access: domain=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)" + threadDelay 100000 + bob ##> ("/_connect plan 1 " <> shortLink2) + bob <## "group link: channel has no active relays, please try to join later" + bob ##> "/_connect plan 1 #team.simplex resolve=all" + bob <## "group link: known group #team" + bob <## "SimpleX name: #team (verified)" + bob <## "use #team to send messages" + where + teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) + +testPlanNameChannelNoRelays :: HasCallStack => TestParams -> IO () +testPlanNameChannelNoRelays ps = withSmpServerAndNames $ \reg -> + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] + (channelLink, _) <- prepareChannel1Relay "team" alice cath + alice ##> "/ad" + (contactLink, _) <- getContactLinks alice True + registerName reg teamName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) + alice ##> "/_set domain 1 team.simplex" + alice <## "new contact address set" + 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)" + threadDelay 100000 + 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 <## "contact address: ok to connect" + _ <- getTermLine bob + bob ##> "/_connect plan 1 @nobody.simplex resolve=never" + bob <## "no matching chat found, name resolution is disabled" + where + teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) + +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)) + setGroupNamesStale bob + setContactNamesStale bob + bob ##> "/_connect plan 1 team.simplex" + knownTeamPlan bob + bob ##> "/_connect plan 1 #team.simplex resolve=never" + knownTeamPlan bob + +testPlanLocalChats :: HasCallStack => TestParams -> IO () +testPlanLocalChats = withTeamChats $ \_reg _contactLink _channelLink alice bob -> do + setContactNamesStale bob + planLocal bob "/_connect plan 1 team.simplex resolve=never" `shouldReturn` (["team", "alice"], True) + planLocal bob "/_connect plan 1 #team.simplex resolve=never" `shouldReturn` (["team"], False) + planLocal bob "/_connect plan 1 @team.simplex resolve=never" `shouldReturn` (["alice"], True) + bob ##> "/_connect plan 1 @team.simplex resolve=all" + bob <## "contact address: known contact alice" + bob <## "SimpleX name: @team.simplex (verified)" + bob <## "use @alice to send messages" + planLocal bob "/_connect plan 1 team.simplex resolve=never" `shouldReturn` (["team", "alice"], False) + planLocal alice "/_connect plan 1 team.simplex resolve=never" `shouldReturn` (["team"], True) + planLocal alice "/_connect plan 1 @team.simplex resolve=never" `shouldReturn` ([], True) + +teamSimplexName :: SimplexNameInfo +teamSimplexName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" []) + +knownTeamPlan :: HasCallStack => TestCC -> IO () +knownTeamPlan cc = do + cc <## "group link: known group #team" + cc <## "SimpleX name: #team (verified)" + cc <## "use #team to send messages" + +withTeamChats :: HasCallStack => (NameRegistry -> String -> String -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +withTeamChats test ps = withSmpServerAndNames $ \reg -> + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] + (channelLink, _) <- prepareChannel1Relay "team" alice cath + alice ##> "/ad" + (contactLink, _) <- getContactLinks alice True + registerName reg teamSimplexName (contactAndChannelNameRecord "team.simplex" (T.pack contactLink) (T.pack channelLink)) + alice ##> "/public group access #team domain=team.simplex" + 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" + bob ##> "/c #team.simplex" + bob <## "#team: connection started" + concurrentlyN_ + [ bob + <### [ "#team: joining the group (connecting to relay cath)...", + "#team: you joined the group (connected to relay cath)" + ] + , do + cath <## "bob (Bob): accepting request to join group #team..." + cath <## "#team: bob joined the group" + , alice <### [EndsWith "introduced bob (Bob) in the channel"] + ] + bob ##> "/c @team.simplex" + bob <## "alice: connection started" + alice <## "bob_1 (Bob) wants to connect to you!" + alice <## "to accept: /ac bob_1" + alice <## "to reject: /rc bob_1 (the sender will NOT be notified)" + alice ##> "/ac bob_1" + alice <## "bob_1 (Bob): accepting contact request, you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob_1 (Bob): contact is connected") + test reg contactLink channelLink alice bob + testNameLinkOrWarning :: IO () testNameLinkOrWarning = do linkOrWarning NTContact (registered Nothing Nothing contactRecord) `shouldBe` Right contactLink @@ -711,6 +888,7 @@ testNameLinkOrWarning = do linkOrWarning NTContact (NRRegistered Nothing Nothing (Just NRRCommunity) contactRecord) `shouldBe` Right contactLink linkOrWarning NTContact (registered (Just 900) (Just 2000) contactRecord) `shouldBe` Left (NWExpired (utc 900) (Just $ utc 2000)) linkOrWarning NTContact (registered (Just 900) Nothing contactRecord) `shouldBe` Left (NWExpired (utc 900) Nothing) + linkOrWarning NTContact (registered (Just 800) (Just 900) contactRecord) `shouldBe` Left (NWExpired (utc 800) Nothing) linkOrWarning NTContact (NRAvailable $ pricing 5 M.empty) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 2000) 2) linkOrWarning NTContact (NRAvailable $ pricing 3 (M.fromList [(5, USDCents 5000)])) `shouldBe` Left (NWAvailable $ NamePrice (USDCents 10000) 2) linkOrWarning NTContact (NRAvailable $ pricing 6 M.empty) `shouldBe` Left NWNotRegistered