From ddc18b326bc13b8ced2882cb4217101d899e56b4 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 30 Sep 2026 10:10:29 +0200 Subject: [PATCH] fix freshness bug with local chats: When core marks a chat verified for a name, it now first un-verifies the user's other chats of the same kind: - a contact name covers contacts and business chats; - a channel name covers channels. --- apps/ios/Shared/Model/AppAPITypes.swift | 4 +- .../Shared/Views/NewChat/NewChatView.swift | 18 +++--- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../common/views/newchat/ConnectPlan.kt | 8 +-- bots/api/TYPES.md | 2 + bots/src/API/TypeInfo.hs | 1 + .../types/typescript/src/types.ts | 2 + .../src/simplex_chat/types/_types.py | 2 + plans/2026-09-22-name-lookup-core-api.md | 2 +- plans/2026-09-28-name-warnings.md | 8 +-- src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 39 +++++++----- src/Simplex/Chat/Store/Direct.hs | 23 ++++++- src/Simplex/Chat/Store/Groups.hs | 5 +- src/Simplex/Chat/View.hs | 4 +- tests/ChatTests/Names.hs | 63 +++++++++++++++++-- 16 files changed, 139 insertions(+), 50 deletions(-) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e88eeadbc3..acffafa990 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1489,7 +1489,7 @@ enum InvitationLinkPlan: Decodable, Hashable { } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool, existingChat_: ChatInfo?) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1504,7 +1504,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable { } enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool, existingChat_: ChatInfo?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 49ccbf5d01..712977b90a 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1426,10 +1426,12 @@ private func showNameWarningAlert( } } -private func nameChatId(_ name: SimplexNameInfo?) async -> ChatId? { - guard let name else { return nil } - let plan = await apiConnectPlan(connLink: name.shortStr, resolveMode: .never, inProgress: BoxedValue(false)) - return await MainActor.run { knownChatId(plan) } +@MainActor private func nameChatId(_ chatInfo: ChatInfo?) -> ChatId? { + guard let chatInfo else { return nil } + if ChatModel.shared.getChat(chatInfo.id) == nil { + ChatModel.shared.addChat(Chat(chatInfo: chatInfo, chatItems: [])) + } + return chatInfo.id } private func showOtherNameAlert(_ otherSimplexName: SimplexNameInfo, connectOtherButton: String, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)?) { @@ -1605,10 +1607,10 @@ func planAndConnect( } case let .contactAddress(cap, _): switch cap { - case let .ok(contactSLinkData_, ownerVerification, addressChanged): + case let .ok(contactSLinkData_, ownerVerification, addressChanged, existingChat_): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") - let existingChatId = await nameChatId(filterKnownContact == nil && addressChanged ? planSimplexName : nil) + let existingChatId = await nameChatId(filterKnownContact == nil ? existingChat_ : nil) await MainActor.run { showPrepareContactAlert( connectionLink: connectionLink, @@ -1699,10 +1701,10 @@ func planAndConnect( } case let .groupLink(glp, _): switch glp { - case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, addressChanged): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, addressChanged, existingChat_): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") - let existingChatId = await nameChatId(filterKnownGroup == nil && addressChanged ? planSimplexName : nil) + let existingChatId = await nameChatId(filterKnownGroup == nil ? existingChat_ : nil) await MainActor.run { showPrepareGroupAlert( connectionLink: connectionLink, 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 580328c0b7..3a77f58582 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 @@ -7472,7 +7472,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): 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("ownLink") object OwnLink: ContactAddressPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() @@ -7482,7 +7482,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): 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("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/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 4e9ad5eb35..c667d9c2f6 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 @@ -290,7 +290,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.contactAddressPlan.addressChanged, - openExistingChat = if (filterKnownContact == null && connectionPlan.contactAddressPlan.addressChanged) localNameChat(rhId, planSimplexName)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, + openExistingChat = if (filterKnownContact == null) localNameChat(rhId, connectionPlan.contactAddressPlan.existingChat_)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, close, cleanup ) @@ -388,7 +388,7 @@ private suspend fun planAndConnectTask( connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink, addressChanged = connectionPlan.groupLinkPlan.addressChanged, - openExistingChat = if (filterKnownGroup == null && connectionPlan.groupLinkPlan.addressChanged) localNameChat(rhId, planSimplexName)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, + openExistingChat = if (filterKnownGroup == null) localNameChat(rhId, connectionPlan.groupLinkPlan.existingChat_)?.let { chat -> { openChat_(chatModel, rhId, close, chat); cleanup() } } else null, close, cleanup ) @@ -888,8 +888,8 @@ fun showPrepareContactAlert( ) } -private suspend fun localNameChat(rhId: Long?, name: SimplexNameInfo?): Chat? = - name?.let { knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, it.shortStr, PlanResolveMode.PRMNever, inProgress = mutableStateOf(false))) }?.let { chatModel.getChat(it) } +private suspend fun localNameChat(rhId: Long?, chatInfo: ChatInfo?): Chat? = + chatInfo?.let { chatModel.getChat(it.id) ?: Chat(remoteHostId = rhId, chatInfo = it, chatItems = emptyList()).also { chat -> chatModel.chatsContext.addChat(chat) } } private fun showOtherNameAlert(rhId: Long?, otherSimplexName: SimplexNameInfo, connectOtherButton: String, close: (() -> Unit)?, cleanup: (() -> Unit)?) { AlertManager.privacySensitive.showAlertDialogButtonsColumn( diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f285b7f1ea..5770f87991 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1930,6 +1930,7 @@ Ok: - contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? - addressChanged: bool +- existingChat_: [ChatInfo](#chatinfo)? OwnLink: - type: "ownLink" @@ -2541,6 +2542,7 @@ Ok: - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? - ownerVerification: [OwnerVerification](#ownerverification)? - addressChanged: bool +- existingChat_: [ChatInfo](#chatinfo)? OwnLink: - type: "ownLink" diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 9105ca0ba4..e2c5f78a7a 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -176,6 +176,7 @@ toTypeInfo tr = "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] "ACreatedConnLink" -> ST "CreatedConnLink" [] + "AChatInfo" -> ST "ChatInfo" [] "UserChatRelay'" -> ST "UserChatRelay" [] "CChatItem" -> ST "ChatItem" [] "FormatColor" -> ST "Color" [] diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 9102711b58..1e052d6369 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2211,6 +2211,7 @@ export namespace ContactAddressPlan { contactSLinkData_?: ContactShortLinkData ownerVerification?: OwnerVerification addressChanged: boolean + existingChat_?: ChatInfo } export interface OwnLink extends Interface { @@ -2852,6 +2853,7 @@ 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/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 963da7c158..101f6aa307 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -1537,6 +1537,7 @@ class ContactAddressPlan_ok(TypedDict): contactSLinkData_: NotRequired["ContactShortLinkData"] ownerVerification: NotRequired["OwnerVerification"] addressChanged: bool + existingChat_: NotRequired["ChatInfo"] class ContactAddressPlan_ownLink(TypedDict): type: Literal["ownLink"] @@ -1984,6 +1985,7 @@ 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 5bc6a7341b..ba4a86d2e9 100644 --- a/plans/2026-09-22-name-lookup-core-api.md +++ b/plans/2026-09-22-name-lookup-core-api.md @@ -181,7 +181,7 @@ Review on 2026-09-24 reversed decision 6. Each decision below was taken by the a 1. **Storage.** `contact_profiles.contact_domain_resolved_at` and `contact_domain_expires_at`; `groups.group_domain_resolved_at` and `group_domain_expires_at` — beside `contact_domain_verified` and `group_domain_verified`, which record whether the name checked out; these record when it was last resolved and when its registration expires. `TEXT` in SQLite, `TIMESTAMPTZ` in Postgres, `_at` as in `badge_purchases.expires_at`. One migration per backend. 2. **Rule.** Under `PRMUnknown`, a known chat reached by a name is re-resolved when `resolved_at` is `NULL` or over a day old, or `expires_at` has passed. `PRMAll` always resolves; `PRMNever` never does. A name with no chat is resolved on every call and nothing is stored; the user's own address is not a chat, so it too is resolved on every call. -3. **Writes.** Wherever core sets a verification flag after a resolution, it also sets `resolved_at` to now and `expires_at` to the registration's expiry, or `NULL` when the caller does not have it — then only the one-day limit applies until the next resolution. That is `setContactDomainVerified`, `setGroupDomainVerified`, and `createPreparedContact`, which inserts a chat created by name already verified. The plan passes the expiry of the registration it resolved, and `updateGroupFromLinkData` takes it from its callers, since it must not resolve; `/_verify domain` and `APISetPublicGroupAccess` pass `NULL`. Two paths gain a write: a re-resolution confirming the name still resolves to the known chat, and one that answers the chat without a warning because the name no longer has a link of its kind or is not registered (`plans/2026-09-28-name-warnings.md`, N7 and N11). Both re-set the flag to `True`, a no-op, since only verified chats are found by name. +3. **Writes.** Wherever core sets a verification flag after a resolution, it also sets `resolved_at` to now and `expires_at` to the registration's expiry, or `NULL` when the caller does not have it — then only the one-day limit applies until the next resolution. That is `setContactDomainVerified`, `setGroupDomainVerified`, and `createPreparedContact`, which inserts a chat created by name already verified. The plan passes the expiry of the registration it resolved, and `updateGroupFromLinkData` takes it from its callers, since it must not resolve; `/_verify domain` and `APISetPublicGroupAccess` pass `NULL`. Two paths gain a write: a re-resolution confirming the name still resolves to the known chat, and one that answers the chat without a warning because the name no longer has a link of its kind or is not registered (`plans/2026-09-28-name-warnings.md`, N7 and N11). Both re-set the flag to `True`, a no-op, since only verified chats are found by name. Setting the flag to `True` clears it on the user's other chats of the name's kind (contacts and business chats for a contact name, channels for a channel name), so after "Open new chat" in 3c the name finds the new chat. 4. **Moved name.** When re-resolution finds the name resolves elsewhere (3c), nothing is written to the old chat. It stays stale, so each default lookup re-resolves and reports the new address; `resolve=never` still returns the old chat. 5. **Reading.** A store function reads the two columns for the one chat being planned. `LocalProfile` and `GroupInfo` do not change, so the columns never reach the UIs; loading them there would touch 19 queries in 6 store files and both types' JSON. 6. **UIs.** Both apps lose the cache — the preference, `SimplexNameResolved`, the local probe before resolving, and the invalidation in `UserAddressView` — and plan a name with the default mode. diff --git a/plans/2026-09-28-name-warnings.md b/plans/2026-09-28-name-warnings.md index 38589340f0..58db4fa5aa 100644 --- a/plans/2026-09-28-name-warnings.md +++ b/plans/2026-09-28-name-warnings.md @@ -129,7 +129,7 @@ When the name is resolved and has a live link of the kind not planned, `otherSim | 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, and 3e: "bakery.simplex also leads to …", with Join channel or Connect | | 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 chat that the name's local lookup finds), 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 plan's `existingChat_`), 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 | | 14 | Own; the name is available | "Your name has expired", "from $X per year" | the same alert, "$Y for 2 years" | @@ -191,7 +191,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` 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` 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; - **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. @@ -233,7 +233,7 @@ A missing `graceUntil` drops the second clause of the expiry lines. `otherSimple - 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 is offered only when the name's local lookup (`resolve=never`) finds a chat, so the own address gets Cancel (N20). +- **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 "Connect to" row passes the filters, as a pasted link does (N17). - **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, a message and its buttons. @@ -269,7 +269,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 chat found by the name's local lookup), no Cancel | +| N10 | 3c from a message | Open new chat, and Open existing chat (the plan's `existingChat_`), 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 | diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1e0be89129..21204789de 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1180,7 +1180,7 @@ data InvitationLinkPlan deriving (Show) data ContactAddressPlan - = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool} + = CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} | 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} + = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, addressChanged :: Bool, existingChat_ :: Maybe AChatInfo} | 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 e66d2bae17..cc4ddd5460 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2443,7 +2443,7 @@ processChatCommand cxt nm = \case toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do - plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing False) Nothing) + 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 DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing @@ -4534,10 +4534,10 @@ processChatCommand cxt nm = \case Just r@(_, p) | knownChat p -> pure r _ -> throwError e linkPlan expiresAt l' = case known_ of - Just r@(l, _) + Just r@(l, p) | knownLinkOf l == Just l' -> confirmKnown expiresAt l' r | otherwise -> - (second setAddressChanged <$> resolvedPlan expiresAt l') `catchAllErrors` \case + (second (setAddressChanged p) <$> resolvedPlan expiresAt l') `catchAllErrors` \case ChatError (CESimplexDomainNotReady _ SDEUnknownDomain) -> pure r e -> throwError e Nothing -> resolvedPlan expiresAt l' @@ -4575,7 +4575,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 @@ -4638,8 +4638,8 @@ processChatCommand cxt nm = \case 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 @@ -4706,13 +4706,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 + _ -> plan $ CAPOk cld ov False Nothing Just (RcvDirectMsgConnection Connection {connStatus} Nothing) - | connStatus == ConnPrepared -> plan $ CAPOk cld ov False + | connStatus == ConnPrepared -> plan $ CAPOk cld ov False Nothing | otherwise -> plan CAPConnectingConfirmReconnect Just (RcvDirectMsgConnection _ (Just ct)) | not (contactReady ct) && contactActive ct -> plan $ CAPConnectingProhibit ct - | contactDeleted ct -> plan $ CAPOk cld ov False + | contactDeleted ct -> plan $ CAPOk cld ov False Nothing | otherwise -> plan $ CAPKnown ct -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Nothing [] @@ -4728,12 +4728,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) -> plan $ GLPOk linkInfo gld ov False Nothing -- 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 + | otherwise -> plan $ GLPOk linkInfo gld ov False Nothing (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 @@ -4742,7 +4742,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 + | otherwise = plan $ GLPOk linkInfo gld ov False Nothing where plan p = pure $ CPGroupLink p Nothing contactCReqSchemas :: ConnReqContact -> (ConnReqContact, ConnReqContact) @@ -5189,11 +5189,18 @@ setNameWarning w = \case NWReservedForCommunity -> Just w _ -> Nothing -setAddressChanged :: ConnectionPlan -> ConnectionPlan -setAddressChanged = \case - CPContactAddress (CAPOk cld ov _) w_ -> CPContactAddress (CAPOk cld ov True) w_ - CPGroupLink (GLPOk li gld ov _) w_ -> CPGroupLink (GLPOk li gld ov True) w_ +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 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/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 145eeeed07..7a4935a728 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -52,6 +52,7 @@ module Simplex.Chat.Store.Direct getContactIdByName, updateContactProfile, setContactDomainVerified, + unverifyNameChats, getContactDomainResolution, updateContactUserPreferences, updateContactAlias, @@ -112,11 +113,11 @@ import Data.Type.Equality import Simplex.Chat.Badges (badgeToRow) import Simplex.Chat.Messages import Simplex.Chat.Store.Shared -import Simplex.Chat.Names (SimplexDomainClaim (..)) +import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), SConnectionMode (..), SimplexNameInfo (..), UserId) +import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionModeI (..), ConnectionRequestUri, CreatedConnLink (..), SConnectionMode (..), SimplexDomain, SimplexNameInfo (..), SimplexNameType (..), UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -592,8 +593,9 @@ updateContactProfile db cxt user@User {userId} c p' = do pure $ Right c {localDisplayName = ldn, profile, mergedPreferences} setContactDomainVerified :: DB.Connection -> User -> Contact -> Bool -> Maybe UTCTime -> IO Contact -setContactDomainVerified db User {userId} ct@Contact {contactId, profile = p} verified expiresAt = do +setContactDomainVerified db User {userId} ct@Contact {contactId, profile = p@LocalProfile {contactDomain}} verified expiresAt = do currentTs <- getCurrentTime + when verified $ forM_ contactDomain $ unverifyNameChats db userId NTContact . claimDomain DB.execute db [sql| @@ -603,6 +605,21 @@ setContactDomainVerified db User {userId} ct@Contact {contactId, profile = p} ve (BI verified, currentTs, expiresAt, userId, contactId) pure (ct {profile = p {contactDomainVerified = Just verified}} :: Contact) +unverifyNameChats :: DB.Connection -> UserId -> SimplexNameType -> SimplexDomain -> IO () +unverifyNameChats db userId nameType domain = case nameType of + NTContact -> do + DB.execute db "UPDATE contact_profiles SET contact_domain_verified = 0 WHERE user_id = ? AND contact_domain = ? AND contact_domain_verified = 1" (userId, domain) + unverifyGroups " AND business_chat IS NOT NULL" + NTPublicGroup -> unverifyGroups " AND business_chat IS NULL" + where + unverifyGroups businessCond = DB.execute db (unverifyGroupsQuery <> businessCond) (userId, domain) + unverifyGroupsQuery = + [sql| + UPDATE groups SET group_domain_verified = 0 + WHERE user_id = ? AND group_domain_verified = 1 + AND group_profile_id IN (SELECT group_profile_id FROM group_profiles WHERE group_domain = ?) + |] + getContactDomainResolution :: DB.Connection -> User -> Contact -> IO (Maybe (UTCTime, Maybe UTCTime)) getContactDomainResolution db User {userId} Contact {profile = LocalProfile {profileId}} = maybeFirstRow id $ diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a44b4c9480..eec0b8298e 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -2744,8 +2744,11 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, safeDeleteLDN db user localDisplayName setGroupDomainVerified :: DB.Connection -> User -> GroupInfo -> Bool -> Maybe UTCTime -> IO GroupInfo -setGroupDomainVerified db User {userId} g@GroupInfo {groupId} verified expiresAt = do +setGroupDomainVerified db User {userId} g@GroupInfo {groupId, businessChat} verified expiresAt = do currentTs <- getCurrentTime + when verified $ do + domain_ <- maybeFirstRow fromOnly $ DB.query db "SELECT gp.group_domain FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? AND g.group_id = ? AND gp.group_domain IS NOT NULL" (userId, groupId) + forM_ domain_ $ unverifyNameChats db userId (if isJust businessChat then NTContact else NTPublicGroup) DB.execute db "UPDATE groups SET group_domain_verified = ?, group_domain_resolved_at = ?, group_domain_expires_at = ? WHERE user_id = ? AND group_id = ?" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index be1f16d033..54fb46d1d2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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 b53ca6963d..11a087783d 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -6,17 +8,21 @@ module ChatTests.Names where import ChatClient import ChatTests.DBUtils -import ChatTests.Groups (memberJoinChannel, prepareChannel', prepareChannel1Relay) +import ChatTests.Groups (memberJoinChannel, memberJoinChannel', prepareChannel', prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent.Async (concurrently_) +import Control.Monad.Reader (runReaderT) +import Data.ByteString (ByteString) import Data.Int (Int64) import qualified Data.Map.Strict as M import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import NameResolver -import Simplex.Chat.Controller (ConnectionPlan (..), ContactAddressPlan (..), NamePrice (..), NameWarning (..)) -import Simplex.Chat.Library.Commands (nameLinkOrWarning, setNameWarning) +import Simplex.Chat.Controller (ChatResponse (..), ConnectionPlan (..), ContactAddressPlan (..), GroupLinkPlan (..), NamePrice (..), NameWarning (..)) +import Simplex.Chat.Library.Commands (execChatCommand', nameLinkOrWarning, parseChatCommand, setNameWarning) +import Simplex.Chat.Messages (AChatInfo (..), ChatInfo (..)) +import Simplex.Chat.Types (Contact (..), GroupInfo (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Names.Record (NamePricing (..), NameRecord, NameRegistration (..), NameReservedReason (..), USDCents (..)) @@ -41,6 +47,7 @@ chatNamesTests = do it "reserved for another reason" testPlanNameReservedOther it "registered with no usable link" testPlanNameNoValidLink it "known chat and own name, name moved to a new address" testPlanKnownNameAddressChanged + it "known chat, name moved, new chat opened" testPlanKnownNameNewChatOpened it "known chat, 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 @@ -407,6 +414,20 @@ knownAlicePlan bob = do bob <## "SimpleX name: @alice.simplex (verified)" bob <## "use @alice to send messages" +planExistingChat :: TestCC -> ByteString -> IO (Maybe String) +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_ + _ -> 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 + connectBobByName :: HasCallStack => TestCC -> TestCC -> IO () connectBobByName alice bob = do bob ##> "/c @alice.simplex" @@ -574,6 +595,7 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> alice ##> "/_connect plan 1 @alice.simplex" alice <## "contact address: ok to connect, address changed" _ <- getTermLine alice + planExistingChat alice "/_connect plan 1 @alice.simplex" `shouldReturn` Nothing setContactNamesStale bob bob ##> "/_connect plan 1 @alice.simplex" bob <## "contact address: ok to connect, address changed" @@ -581,9 +603,33 @@ testPlanKnownNameAddressChanged ps = withSmpServerAndNames $ \reg -> bob ##> "/c @alice.simplex" bob <## "contact address: ok to connect, address changed" _ <- getTermLine bob + planExistingChat bob "/_connect plan 1 @alice.simplex" `shouldReturn` Just "alice" bob ##> "/_connect plan 1 @alice.simplex resolve=never" knownAlicePlan bob +testPlanKnownNameNewChatOpened :: HasCallStack => TestParams -> IO () +testPlanKnownNameNewChatOpened 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 + cath ##> "/ad" + (cathLink, cathFullLink) <- getContactLinks cath True + registerName reg aliceSimplexName (contactNameRecord "alice.simplex" (T.pack cathLink)) + cath ##> "/_set domain 1 alice.simplex" + cath <## "new contact address set" + bob ##> "/_connect plan 1 @alice.simplex resolve=all" + bob <## "contact address: ok to connect, address changed" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> cathFullLink <> " " <> cathLink <> " domain=alice.simplex " <> contactSLinkData) + bob <## "cath: contact is prepared" + failNameResolution reg aliceSimplexName + bob ##> "/_connect plan 1 @alice.simplex" + bob <## "contact address: known prepared contact cath" + bob <## "SimpleX name: @alice.simplex (verified)" + testPlanNameResolverFailed :: HasCallStack => TestParams -> IO () testPlanNameResolverFailed = withAliceName $ \reg _r _alice bob -> do failNameResolution reg brokenName @@ -626,7 +672,7 @@ testPlanChannelNameMoved ps = withSmpServerAndNames $ \reg -> alice ##> "/c #team.simplex" alice <## "group link: own link for group #team" alice <##. "your SimpleX name team.simplex expired on " - (shortLink2, _) <- prepareChannel' 2 "team2" alice cath + (shortLink2, fullLink2) <- 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" @@ -635,7 +681,14 @@ testPlanChannelNameMoved ps = withSmpServerAndNames $ \reg -> bob ##> "/_connect plan 1 #team.simplex resolve=all" bob <## "group link: ok to connect via relays, address changed" _ <- getTermLine bob - pure () + planExistingChat bob "/_connect plan 1 #team.simplex resolve=all" `shouldReturn` Just "team" + memberJoinChannel' "team2" 2 1 1 1 [cath] [alice] shortLink2 fullLink2 bob + bob ##> "/_verify domain #2" + bob <## "SimpleX name #team verified" + bob ##> "/_connect plan 1 #team.simplex resolve=never" + bob <## "group link: known group #team2" + bob <## "SimpleX name: #team (verified)" + bob <## "use #team2 to send messages" where teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" [])