From d6a14ade543db26a0c16293f0627c8ae820b1889 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 10 Jun 2026 11:33:21 +0300 Subject: [PATCH] [Chat] revalidate join links on leave (#10833) --- .../intents/GroupChatJoinDialog.tsx | 8 ++++- src/state/queries/join-links.ts | 29 +++++++++++++++++++ .../queries/messages/leave-conversation.ts | 4 +++ .../queries/messages/list-conversations.tsx | 6 ++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx index dd3d8cb6e8..e8b28e6abd 100644 --- a/src/components/intents/GroupChatJoinDialog.tsx +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -7,6 +7,7 @@ import { } from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' @@ -15,7 +16,10 @@ import {isNetworkError} from '#/lib/strings/errors' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' +import { + invalidateJoinLinkPreviewsForCode, + useJoinLinkPreviewsQuery, +} from '#/state/queries/join-links' import {useRequestJoinGroupChat} from '#/state/queries/messages/request-join-group-chat' import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat' import {useSession} from '#/state/session' @@ -78,6 +82,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { const {hasSession} = useSession() const moderationOpts = useModerationOpts() const navigation = useNavigation() + const queryClient = useQueryClient() const {data, error, isLoading} = useJoinLinkPreviewsQuery({ codes: code ? [code] : undefined, @@ -88,6 +93,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { const {mutate: joinGroupChat, isPending: isJoinPending} = useRequestJoinGroupChat({ onSuccess: data => { + if (code) void invalidateJoinLinkPreviewsForCode(queryClient, code) switch (data.status) { case 'pending': control.close(() => { diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index 01e2d3fdf6..2381f467f4 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -76,6 +76,35 @@ export function invalidateJoinLinkPreviewsForCode( }) } +/** + * Invalidate any join link preview queries that resolved to the given convo. + * The code isn't always known to the viewer (e.g. when they're a regular + * member), so we match on the convoId carried by the resolved preview instead. + * Use this when the viewer's membership changes (e.g. they leave or are removed) + * so cached previews refetch and reflect their new viewer state. + */ +export function invalidateJoinLinkPreviewsForConvo( + queryClient: QueryClient, + convoId: string, +) { + return queryClient.invalidateQueries({ + predicate: query => { + const [root] = query.queryKey + if (root !== joinLinkPreviewQueryKeyRoot) return false + const data = query.state.data as + | ChatBskyGroupGetJoinLinkPreviews.OutputSchema + | undefined + return ( + data?.joinLinkPreviews.some( + preview => + ChatBskyGroupDefs.isJoinLinkPreviewView(preview) && + preview.convoId === convoId, + ) ?? false + ) + }, + }) +} + async function fetchJoinLinkPreviews({ agent, codes, diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts index 620ce7ee2c..a9614981e5 100644 --- a/src/state/queries/messages/leave-conversation.ts +++ b/src/state/queries/messages/leave-conversation.ts @@ -11,6 +11,7 @@ import { import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' +import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links' import {useAgent} from '#/state/session' import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' @@ -72,6 +73,9 @@ export function useLeaveConvo( }, onSuccess: data => { void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + if (convoId) { + void invalidateJoinLinkPreviewsForConvo(queryClient, convoId) + } onSuccess?.(data) }, onError: (error, _, context) => { diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index f7ad22fbae..9cb4e59603 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -18,6 +18,7 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useMessagesEventBus} from '#/state/messages/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links' import {useAgent, useSession} from '#/state/session' import {parseConvoView} from '#/components/dms/util' import {useAgeAssurance} from '#/ageAssurance' @@ -257,6 +258,11 @@ export function ListConvosProviderInner({ debouncedRefetch() } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { deleteConvoFromAllLists(log.convoId) + // The viewer is no longer in this convo (they left on another + // device, or were removed - removed members receive a + // logLeaveConvo, not a logRemoveMember). Refetch any cached join + // link preview so its viewer state reflects the lost membership. + void invalidateJoinLinkPreviewsForConvo(queryClient, log.convoId) } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { updateConvoInAllLists(log.convoId, convo => { if (