From f3541bccb521577324ea4879bde73409837c0209 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:02:04 +0000 Subject: [PATCH] core: the store-only plan of a group short link finds a group joined with /c A directory result was recognised as your chat only when the group was prepared from its short link, so a group joined with /c showed as a result with Open beside its own chat. A /c join stores the full link it fetched under the short link on its connection; the store-only plan (resolve=never) now reads it and plans it as the online plan does after fetching the link, so the group reads as known, and a pending join as connecting. Online plans are unchanged, and an unknown link is still "not resolved locally". Groups joined by invitation stay unrecognised, as documented. Run: 012-search-followup, slice 2 --- apps/ios/product/gaps.md | 10 ++-- apps/ios/product/rules.md | 2 +- apps/ios/product/views/chat-list.md | 2 +- apps/ios/spec/client/chat-list.md | 4 +- apps/multiplatform/product/gaps.md | 14 +++--- apps/multiplatform/product/rules.md | 2 +- apps/multiplatform/product/views/chat-list.md | 2 +- apps/multiplatform/spec/client/chat-list.md | 4 +- src/Simplex/Chat/Library/Commands.hs | 14 +++++- src/Simplex/Chat/Store/Connections.hs | 13 +++++ .../SQLite/Migrations/chat_query_plans.txt | 8 +++ tests/ChatTests/Groups.hs | 50 +++++++++++++++++-- 12 files changed, 99 insertions(+), 26 deletions(-) diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index dc1a025490..0d4d31cdd2 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -64,16 +64,16 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages ## Online Search -### GAP: Groups joined without a prepared group still appear as results with Open +### GAP: Groups joined without the listed short link still appear as results with Open **Source:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search) -A directory entry is matched to a local chat by a store-only plan of its link (`resolve=never`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row: +A directory entry is matched to a local chat by a store-only plan of its link (`resolve=never`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row: -- a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert; +- a group joined with a full link (an older app) when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert; - a member added by invitation, or one who joined through a link the owner has since replaced: the online plan is "ok", so Open prepares and opens a second copy of the group without asking (before, the "Open group?" confirm led to the same copy). -Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable only for entries the directory sends with a full link, since a short link's connecting plan needs a network fetch. A core test pins the store-only contract, including the `/c` case. +Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable for a short-link entry with a pending `/c` join and for entries the directory sends with a full link. Core tests pin the store-only contract, including the `/c` and invited-member cases. -**REC:** Let the store-only plan recognise joins made without a prepared group (a lookup by the connection's `via_short_link_contact`, mapped as the online plan maps a join request); it would cover `/c` joins, not invited members. +**REC:** Recognising invited members needs a link in the group record that matches the listed one, which they don't have today: the owner would have to share the group's current link, a protocol change. ### GAP: The count of results left can include listings the directory never sends **Source:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search) diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md index a1feb0e74d..82285c7c10 100644 --- a/apps/ios/product/rules.md +++ b/apps/ios/product/rules.md @@ -167,7 +167,7 @@ **Spec:** [spec/api.md](../spec/api.md#8-directory-search-and-connection-plans) ### RULE-27: Nothing is shown twice -**Rule:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link or joined with the full link the directory sends. A group joined without a prepared group (`/c`, an older app, a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking. +**Rule:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking. **Enforced by:** `DirectorySearchView.swift` (`OnlineSearchModel.rows(shownChatIds:)`, `pageInChats`; `request` drops entries already shown); `DirectorySearch.swift` (`planLocalChatId`, `directoryResultState`); `ChatListView.swift` (`directoryRows`, `chatListNavLink`); `NewChatMenuButton.swift` (`onlineSearchSection`); `ContactListNavLink.swift` (`groupNavLink`). **Spec:** [spec/client/chat-list.md](../spec/client/chat-list.md#11-online-search) diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 9cc3ee47ef..b08919ec40 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -53,7 +53,7 @@ Searching beyond the user's own chats happens only when the user asks, and the r | Load row | The last row of the results: "Load more · N left" when the directory says how many matches are left, "Load more" otherwise; greyed while a page loads. A failed request turns it into "Search failed, tap to retry". Nothing loads more pages automatically | | Page note | When every result of the last page loaded is a chat already in the list, "All results on this page are already in your chats" (secondary colour), followed by the load row when there are more pages | -A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without preparing it first (with an older app, by `/c`, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat. Directory entries that are not group or channel links are ignored. +A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without the listed short link (with a full link from an older app, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat. Directory entries that are not group or channel links are ignored. With the one-hand UI the whole list is mirrored: going up from the search field at the bottom come the local chats, then your chats from the results, then the other results, with the load row at the top, farthest from the field. This order is intended, so the user's own chats stay nearest the field. diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index eec5b72b90..130e2cf8b7 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -395,7 +395,7 @@ For each new entry of a page (not already shown, not repeated in the page), the | `.joining` | `.groupLink(.connectingConfirmReconnect)` or `.groupLink(.connectingProhibit(nil))`: a pending join without a chat | a result with "Already joining" | | `.open` | anything else: `.groupLink(.ok)`, or no plan (`notResolvedLocally` for a link the store doesn't know, which `apiConnectPlan` returns as nil without an alert, or a logged failure) | a result with Open | -The link-type check makes this lookup store-only: the core plans contact-mode links under `.never` from the store, but would fetch an invitation link from its server. A short link reaches a connecting plan only after a network fetch, and the directory sends the short link whenever an entry has one, so `.joining` is reachable only for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change. +The link-type check makes this lookup store-only: the core plans contact-mode links under `.never` from the store, but would fetch an invitation link from its server. The directory sends the short link whenever an entry has one, and a short link reaches a connecting plan from the store only through a pending `/c` join, whose connection stores the full link under the short link, so `.joining` is reachable for a short-link entry with a pending `/c` join and for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change. ### De-duplication @@ -408,7 +408,7 @@ The link-type check makes this lookup store-only: the core plans contact-mode li So nothing is shown twice: a result that is a chat the list shows is hidden, a chat the list doesn't show (the local filter doesn't match it) is drawn once as its own row, and a link repeated across pages was already dropped by the request. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row. -[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row. For a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `.ok`, so Open prepares and opens a second copy of the group without asking. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins this. +[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row. For a group joined with a full link (an older app) when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `.ok`, so Open prepares and opens a second copy of the group without asking. The core tests "short link without resolving: prepared and /c-joined groups known, invited member not found" and "short link without resolving: pending /c join is connecting" (tests/ChatTests/Groups.hs) pin this. ### Rendering diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index b497874006..ab899649ac 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -15,7 +15,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) 8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) 9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search) -10. [Online Search: Groups Joined Without a Prepared Group Still Appear as Results With Open](#gap-10-online-search-groups-joined-without-a-prepared-group-still-appear-as-results-with-open) +10. [Online Search: Groups Joined Without the Listed Short Link Still Appear as Results With Open](#gap-10-online-search-groups-joined-without-the-listed-short-link-still-appear-as-results-with-open) 11. [Online Search: The Count of Results Left Can Include Listings the Directory Never Sends](#gap-11-online-search-the-count-of-results-left-can-include-listings-the-directory-never-sends) --- @@ -354,7 +354,7 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag --- -## GAP-10: Online Search: Groups Joined Without a Prepared Group Still Appear as Results With Open +## GAP-10: Online Search: Groups Joined Without the Listed Short Link Still Appear as Results With Open **Severity:** Low **Category:** UI / UX @@ -362,12 +362,12 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag ### Description -A directory entry is matched to a local chat by a store-only plan of its link (`apiConnectPlan` with `PlanResolveMode.PRMNever`, then `directoryResultState`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row: +A directory entry is matched to a local chat by a store-only plan of its link (`apiConnectPlan` with `PlanResolveMode.PRMNever`, then `directoryResultState`), which finds a group only when it was prepared from that short link (every short-link join in current apps, Open included), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row: -- a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert; +- a group joined with a full link (an older app) when the directory sends the short link: Open's online plan finds it known and shows the "Open group" alert; - a member added by invitation, or one who joined through a link the owner has since replaced: the online plan is `Ok`, so Open prepares and opens a second copy of the group without asking (before, the "Open group?" confirm led to the same copy). -Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable only for entries the directory sends with a full link, since a short link's connecting plan needs a network fetch. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins the store-only contract, including the `/c` case. +Accepted for now: channels, the directory's main content, are always joined through a prepared group. "Already joining" is reachable for a short-link entry with a pending `/c` join and for entries the directory sends with a full link. The core tests "short link without resolving: prepared and /c-joined groups known, invited member not found" and "short link without resolving: pending /c join is connecting" (tests/ChatTests/Groups.hs) pin the store-only contract, including the `/c` and invited-member cases. ### Affected Locations @@ -377,11 +377,11 @@ Accepted for now: channels, the directory's main content, are always joined thro ### Impact -A group the user is already in can appear as a result with Open. For a group joined with `/c`, Open shows the known group's alert and joining is not repeated; for an invited member, Open adds a second, prepared copy of the group to the chat list. +A group the user is already in can appear as a result with Open. For a group joined with a full link, Open shows the known group's alert and joining is not repeated; for an invited member, Open adds a second, prepared copy of the group to the chat list. ### Recommendation -Let the store-only plan recognise joins made without a prepared group (a lookup by the connection's `via_short_link_contact`, mapped as the online plan maps a join request); it would cover `/c` joins, not invited members. +Recognising invited members needs a link in the group record that matches the listed one, which they don't have today: the owner would have to share the group's current link, a protocol change. --- diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md index fd30c0cf69..2857a8a579 100644 --- a/apps/multiplatform/product/rules.md +++ b/apps/multiplatform/product/rules.md @@ -301,7 +301,7 @@ This document specifies invariants enforced by the Android and Desktop (Kotlin/C ### RULE-22: Nothing Is Shown Twice -**Invariant:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link or joined with the full link the directory sends. A group joined without a prepared group (`/c`, an older app, a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking (GAP-10). +**Invariant:** A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list. A result whose link refers to a chat the screen doesn't list MUST be drawn once, as that screen's chat row, which opens the chat, never with Open; two entries that refer to one chat MUST give one row, and an entry repeated across pages MUST be shown once. Only store-derived chat ids may decide this, never a field the directory wrote. When every result of the last page loaded is a chat the local list shows, the results MUST say so ("All results on this page are already in your chats"). Limits: the store-only lookup finds a group only when it was prepared from that short link, joined with `/c` and that short link, or joined with the full link the directory sends. A group joined otherwise (a full link when the directory sends the short link, an invitation, a link the owner has since replaced) appears as a result with Open beside its own chat row; for an invited member, or one who joined through a replaced link, Open prepares and opens a second copy of the group without asking (GAP-10). **Enforcement:** `OnlineSearchModel.request` drops entries already shown; `OnlineSearchModel.rows(shownChatIds)` splits the results at render time with the ids of the chats each screen shows into `yourChats` (each chat id once) and `results`, and computes `pageInChats`; `directoryResultState` derives a result's state from the store-only plan through `planLocalChatId`. `directoryRows` (chat list) and `onlineSearchSection` (New chat) draw `yourChats` with `ChatListNavLinkView` / `ContactListNavLinkView`. diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md index 86b4bd4f9e..cb07843e35 100644 --- a/apps/multiplatform/product/views/chat-list.md +++ b/apps/multiplatform/product/views/chat-list.md @@ -71,7 +71,7 @@ Searching beyond the user's own chats happens only when the user asks, and the r | Load row | The last row of the results: "Load more · N left" when the directory says how many matches are left, "Load more" otherwise; greyed while a page loads. A failed request turns it into "Search failed, tap to retry". Nothing loads more pages automatically | | Page note | When every result of the last page loaded is a chat already in the list, "All results on this page are already in your chats" (secondary colour), followed by the load row when there are more pages | -A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without preparing it first (with an older app, by `/c`, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat (see GAP-10). Directory entries that are not group or channel links are ignored. +A result is not shown twice: an entry for a chat already shown in the list is hidden, and one the list doesn't show is drawn once, as the chat. A group joined without the listed short link (with a full link from an older app, or added by invitation) is not recognised: it appears as a result with "Open" beside its own chat (see GAP-10). Directory entries that are not group or channel links are ignored. With the one-hand UI the whole list is reversed: going up from the search field at the bottom come the local chats, then your chats from the results, then the other results, with the load row at the top, farthest from the field. This order is intended, so the user's own chats stay nearest the field. diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index 4db28de284..506d39b922 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -455,7 +455,7 @@ For each new entry of a page (not already shown, not repeated in the page), the | `Joining` | `GroupLinkPlan.ConnectingConfirmReconnect`, or `ConnectingProhibit` with no `groupInfo_`: a pending join without a chat | a result with "Already joining" | | `Open` | anything else: `GroupLinkPlan.Ok`, or no plan (`NotResolvedLocally` for a link the store doesn't know, which `apiConnectPlan` returns as null without an alert, or a logged failure) | a result with Open | -The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. A short link reaches a connecting plan only after a network fetch, and the directory sends the short link whenever an entry has one, so `Joining` is reachable only for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change. +The link-type check makes this lookup store-only: the core plans contact-mode links under `PRMNever` from the store, but would fetch an invitation link from its server. The directory sends the short link whenever an entry has one, and a short link reaches a connecting plan from the store only through a pending `/c` join, whose connection stores the full link under the short link, so `Joining` is reachable for a short-link entry with a pending `/c` join and for entries the directory sends with a full link. The page is published with its `moreCount`, appended or replacing, only if the generation did not change. ### De-duplication @@ -468,7 +468,7 @@ The link-type check makes this lookup store-only: the core plans contact-mode li So nothing is shown twice: a result that is a chat the list shows is hidden, a chat the list doesn't show (the local filter doesn't match it) is drawn once as its own row, and a link repeated across pages was already dropped by the request. Only store-derived chat ids decide this, never a field the directory wrote. A mismatch in link encoding can only produce a duplicate row. -[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included) or joined with the full link the directory sends. A group joined without a prepared group shows as a result with Open beside its own chat row. For a group (not a channel: `/c` with a channel link prepares it) joined with `/c`, an older app, or a full link when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `Ok`, so Open prepares and opens a second copy of the group without asking. The core test "short link without resolving: prepared and joined group known, /c join not found" (tests/ChatTests/Groups.hs) pins this. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-without-a-prepared-group-still-appear-as-results-with-open). +[GAP] The store-only plan finds a group only when it was prepared from that short link (every short-link join in current apps, Open included), joined with `/c` and that short link (the join connection stores the full link under it), or joined with the full link the directory sends. A group joined otherwise shows as a result with Open beside its own chat row. For a group joined with a full link (an older app) when the directory sends the short link, Open's online plan finds it known and shows the "Open group" alert. For a member added by invitation, or one who joined through a link the owner has since replaced, the online plan is `Ok`, so Open prepares and opens a second copy of the group without asking. The core tests "short link without resolving: prepared and /c-joined groups known, invited member not found" and "short link without resolving: pending /c join is connecting" (tests/ChatTests/Groups.hs) pin this. See [GAP-10](../../product/gaps.md#gap-10-online-search-groups-joined-without-the-listed-short-link-still-appear-as-results-with-open). ### Rendering diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 01d7b2c2ed..b13e43cc52 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4620,8 +4620,8 @@ processChatCommand cxt nm = \case Just (_, CPGroupLink (GLPKnown g _ _ _)) | resolveMode == PRMAllGroups -> resolveKnownGroup g Just r -> pure r + Nothing | resolveMode == PRMNever -> storedLinkPlan Nothing -> do - when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally l' <- resolveSLink (fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays}), cReq) <- getShortLinkConnReq' nm user l' groupSLinkData_ <- liftIO $ decodeLinkUserData cData @@ -4666,6 +4666,18 @@ processChatCommand cxt nm = \case liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g)) Nothing -> (gPlan =<<) <$> getGroupToConnect db cxt user nl' + storedLinkPlan :: CM (ACreatedConnLink, ConnectionPlan) + storedLinkPlan = case nl' of + CTLink l' -> + withFastStore' (\db -> getConnReqContactViaShortLink db user l') >>= \case + Just cReq -> + groupJoinRequestPlan user cReq Nothing Nothing Nothing [] >>= \case + CPGroupLink GLPOk {} -> notResolved + plan -> pure (con l' cReq, plan) + Nothing -> notResolved + CTName _ -> notResolved + where + notResolved = throwChatError CENotResolvedLocally resolveKnownGroup g = do l' <- resolveSLink (FixedLinkData {rootKey = rk}, cData@(ContactLinkData _ UserContactData {owners}), cReq) <- getShortLinkConnReq' nm user l' diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0a58b1cfd9..fdaeed052c 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -15,6 +15,7 @@ module Simplex.Chat.Store.Connections getConnectionEntityKeys, getConnectionEntityByConnReq, getConnectionEntityViaShortLink, + getConnReqContactViaShortLink, getContactConnEntityByConnReqHash, getContactConnsToSub, getUCLConnsToSub, @@ -237,6 +238,18 @@ getConnectionEntityViaShortLink db cxt user@User {userId} shortLink = fmap eithe (Just cReq, connId) -> Right (cReq, connId) _ -> Left $ SEInternalError "no connection request" +getConnReqContactViaShortLink :: DB.Connection -> User -> ShortLinkContact -> IO (Maybe ConnReqContact) +getConnReqContactViaShortLink db User {userId} shortLink = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT via_contact_uri + FROM connections + WHERE user_id = ? AND via_short_link_contact = ? AND via_contact_uri IS NOT NULL LIMIT 1 + |] + (userId, shortLink) + -- search connection for connection plan: -- multiple connections can have same via_contact_uri_hash if request was repeated; -- this function searches for latest connection with contact so that "known contact" plan would be chosen; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index da47b5ccb2..751e82c274 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4324,6 +4324,14 @@ Query: Plan: SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT via_contact_uri + FROM connections + WHERE user_id = ? AND via_short_link_contact = ? AND via_contact_uri IS NOT NULL LIMIT 1 + +Plan: +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) + Query: UPDATE chat_items SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, item_content = ?, item_text = ?, updated_at = ? diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 831e091c7c..a46f124e80 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -143,7 +143,8 @@ chatGroupTests = do it "admin that joined via link introduces member for moderator review" testGLinkReviewIntroduce describe "group link connection plan" $ do it "ok to connect; known group" testPlanGroupLinkKnown - it "short link without resolving: prepared and joined group known, /c join not found" testPlanGroupShortLinkNoResolve + it "short link without resolving: prepared and /c-joined groups known, invited member not found" testPlanGroupShortLinkNoResolve + it "short link without resolving: pending /c join is connecting" testPlanGroupShortLinkNoResolveConnecting it "own group link" testPlanGroupLinkOwn it "group link without contact - connecting" testPlanGroupLinkConnecting it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin @@ -4177,8 +4178,8 @@ testPlanGroupLinkKnown = testPlanGroupShortLinkNoResolve :: HasCallStack => TestParams -> IO () testPlanGroupShortLinkNoResolve = - testChat3 aliceProfile bobProfile cathProfile $ - \alice bob cath -> do + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" @@ -4224,9 +4225,48 @@ testPlanGroupShortLinkNoResolve = bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "#team: new member cath is connected" ] - -- known gap: a group joined with /c, without preparing it, is not found without resolving; a fix makes this fail planNoResolve cath - cath <## "no matching chat found, name resolution is disabled" + cath <## "group link: known group #team" + cath <## "use #team to send messages" + + connectUsers alice dan + addMember "team" alice dan GRMember + dan ##> "/j team" + concurrentlyN_ + [ alice <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member bob (Bob) is connected", + "#team: member cath (Catherine) is connected" + ], + do + bob <## "#team: alice added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected", + do + cath <## "#team: alice added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + planNoResolve dan + dan <## "no matching chat found, name resolution is disabled" + +testPlanGroupShortLinkNoResolveConnecting :: HasCallStack => TestParams -> IO () +testPlanGroupShortLinkNoResolveConnecting ps = do + shortLink <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + threadDelay 100000 + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + fst <$> getGroupLinks alice "team" GRMember True + withNewTestChat ps "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> shortLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> shortLink <> " resolve=never") + bob <## "group link: connecting, allowed to reconnect" testPlanGroupLinkConnecting :: HasCallStack => TestParams -> IO () testPlanGroupLinkConnecting ps = do