diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index a91073eeb8..56c8a5d4a1 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -1,5 +1,6 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import { + type ChatBskyActorDefs, ChatBskyConvoDefs, type ChatBskyConvoListConvos, moderateProfile, @@ -19,7 +20,10 @@ import {useMessagesEventBus} from '#/state/messages/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useAgent, useSession} from '#/state/session' import {parseConvoView} from '#/components/dms/util' +import * as bsky from '#/types/bsky' +import {RQKEY as CONVO_KEY} from './conversation' import {useLeftConvos} from './leave-conversation' +import {listConvoMembersQueryKey} from './list-convo-members' const DEFAULT_LIMIT = 10 export const UNREAD_LIMIT = 20 @@ -138,6 +142,80 @@ export function ListConvosProviderInner({ events => { if (events.type !== 'logs') return + function mutateMembers( + convoId: string, + fn: ( + members: ChatBskyActorDefs.ProfileViewBasic[], + ) => ChatBskyActorDefs.ProfileViewBasic[], + ) { + queryClient.setQueryData( + listConvoMembersQueryKey(convoId), + old => { + if (!old) return // query doesn't exist yet, skip + return fn(old) + }, + ) + } + + function mutateConvoView( + convoId: string, + fn: ( + convo: ChatBskyConvoDefs.ConvoView, + ) => ChatBskyConvoDefs.ConvoView, + ) { + queryClient.setQueryData( + CONVO_KEY(convoId), + old => (old ? fn(old) : old), + ) + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + old => optimisticUpdate(convoId, old, fn), + ) + } + + function handleMemberAdded( + convoId: string, + did: string, + relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], + rev: string, + ) { + const newMember = relatedProfiles.find(r => r.did === did) + if (!newMember) return + // If the optimistic add already added them, skip the + // memberCount bump to avoid double-counting. + const alreadyKnownMember = + queryClient + .getQueryData< + ChatBskyActorDefs.ProfileViewBasic[] + >(listConvoMembersQueryKey(convoId)) + ?.some(m => m.did === did) ?? false + mutateMembers(convoId, list => + list.some(m => m.did === did) ? list : list.concat(newMember), + ) + mutateConvoView(convoId, convo => + addMemberToConvoView(convo, newMember, rev, alreadyKnownMember), + ) + } + + function handleMemberRemoved( + convoId: string, + did: string, + rev: string, + ) { + // If the optimistic remove already dropped them from the full + // list, skip the memberCount decrement to avoid double-counting. + const alreadyRemovedMember = + queryClient + .getQueryData< + ChatBskyActorDefs.ProfileViewBasic[] + >(listConvoMembersQueryKey(convoId)) + ?.some(m => m.did === did) === false + mutateMembers(convoId, list => list.filter(m => m.did !== did)) + mutateConvoView(convoId, convo => + removeMemberFromConvoView(convo, did, rev, alreadyRemovedMember), + ) + } + for (const log of events.logs) { if (ChatBskyConvoDefs.isLogBeginConvo(log)) { debouncedRefetch() @@ -431,6 +509,74 @@ export function ListConvosProviderInner({ rev: log.rev, })), ) + } else if (ChatBskyConvoDefs.isLogAddMember(log)) { + const data = log.message.data + if ( + bsky.dangerousIsType( + data, + ChatBskyConvoDefs.isSystemMessageDataAddMember, + ) + ) { + handleMemberAdded( + log.convoId, + data.member.did, + log.relatedProfiles, + log.rev, + ) + } + // Refetch so the server can refresh the curated members list. + void queryClient.invalidateQueries({ + queryKey: CONVO_KEY(log.convoId), + }) + debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) { + const data = log.message.data + if ( + bsky.dangerousIsType( + data, + ChatBskyConvoDefs.isSystemMessageDataRemoveMember, + ) + ) { + handleMemberRemoved(log.convoId, data.member.did, log.rev) + } + // Refetch so the server can refill the curated members list. + void queryClient.invalidateQueries({ + queryKey: CONVO_KEY(log.convoId), + }) + debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogMemberJoin(log)) { + const data = log.message.data + if ( + bsky.dangerousIsType( + data, + ChatBskyConvoDefs.isSystemMessageDataMemberJoin, + ) + ) { + handleMemberAdded( + log.convoId, + data.member.did, + log.relatedProfiles, + log.rev, + ) + } + void queryClient.invalidateQueries({ + queryKey: CONVO_KEY(log.convoId), + }) + debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogMemberLeave(log)) { + const data = log.message.data + if ( + bsky.dangerousIsType( + data, + ChatBskyConvoDefs.isSystemMessageDataMemberLeave, + ) + ) { + handleMemberRemoved(log.convoId, data.member.did, log.rev) + } + void queryClient.invalidateQueries({ + queryKey: CONVO_KEY(log.convoId), + }) + debouncedRefetch() } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) { queryClient.setQueriesData( {queryKey: [RQKEY_ROOT]}, @@ -616,6 +762,53 @@ function optimisticUpdate( } } +function removeMemberFromConvoView( + convo: ChatBskyConvoDefs.ConvoView, + did: string, + rev: string, + alreadyRemovedMember: boolean, +): ChatBskyConvoDefs.ConvoView { + // Member add/remove/join/leave events are only meaningful for group convos. + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo + const nextMembers = convo.members.filter(m => m.did !== did) + return { + ...convo, + rev, + members: nextMembers, + kind: { + ...convo.kind, + memberCount: alreadyRemovedMember + ? convo.kind.memberCount + : Math.max(0, convo.kind.memberCount - 1), + }, + } +} + +function addMemberToConvoView( + convo: ChatBskyConvoDefs.ConvoView, + member: ChatBskyActorDefs.ProfileViewBasic, + rev: string, + alreadyKnownMember: boolean, +): ChatBskyConvoDefs.ConvoView { + // Member add/remove/join/leave events are only meaningful for group convos. + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo + const alreadyInCuratedList = convo.members.some(m => m.did === member.did) + const nextMembers = alreadyInCuratedList + ? convo.members + : convo.members.concat(member) + return { + ...convo, + rev, + members: nextMembers, + kind: { + ...convo.kind, + memberCount: alreadyKnownMember + ? convo.kind.memberCount + : convo.kind.memberCount + 1, + }, + } +} + function optimisticDelete(chatId: string, old?: ConvoListQueryData) { if (!old) return old diff --git a/src/state/queries/messages/list-convo-members.ts b/src/state/queries/messages/list-convo-members.ts index 5a59a31882..d2ff52dd5c 100644 --- a/src/state/queries/messages/list-convo-members.ts +++ b/src/state/queries/messages/list-convo-members.ts @@ -1,19 +1,16 @@ -import {useEffect} from 'react' -import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' -import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' +import {type ChatBskyActorDefs} from '@atproto/api' +import {type QueryClient, useQuery} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' -import {useMessagesEventBus} from '#/state/messages/events' import {STALE} from '#/state/queries' import {createQueryKey} from '#/state/queries/util' import {useAgent} from '#/state/session' -import * as bsky from '#/types/bsky' const RQKEY_ROOT = 'listConvoMembers' export const listConvoMembersQueryKey = (convoId: string) => createQueryKey(RQKEY_ROOT, {convoId}) -// group chat size is 50, so should fetch the whole list in one go +// Group chat size is at least 50, so should fetch the whole list in one go const LIMIT = 50 export function useListConvoMembersQuery({ @@ -24,94 +21,6 @@ export function useListConvoMembersQuery({ placeholderData?: ChatBskyActorDefs.ProfileViewBasic[] }) { const agent = useAgent() - const queryClient = useQueryClient() - const messagesBus = useMessagesEventBus() - - useEffect(() => { - const unsub = messagesBus.on( - ev => { - if (ev.type !== 'logs') return - - function mutateList( - fn: ( - update: ChatBskyActorDefs.ProfileViewBasic[], - ) => ChatBskyActorDefs.ProfileViewBasic[], - ) { - queryClient.setQueryData( - listConvoMembersQueryKey(convoId), - old => { - if (!old) return // query doesn't exist yet, skip - return fn(old) - }, - ) - } - - for (const log of ev.logs) { - if (ChatBskyConvoDefs.isLogAddMember(log)) { - const data = log.message.data - if ( - bsky.dangerousIsType( - data, - ChatBskyConvoDefs.isSystemMessageDataAddMember, - ) - ) { - const newMember = log.relatedProfiles.find( - r => r.did === data.member.did, - ) - if (newMember) { - mutateList(list => - list.some(m => m.did === newMember.did) - ? list - : list.concat(newMember), - ) - } - } - } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) { - const data = log.message.data - if ( - bsky.dangerousIsType( - data, - ChatBskyConvoDefs.isSystemMessageDataRemoveMember, - ) - ) { - mutateList(list => list.filter(m => m.did !== data.member.did)) - } - } else if (ChatBskyConvoDefs.isLogMemberJoin(log)) { - const data = log.message.data - if ( - bsky.dangerousIsType( - data, - ChatBskyConvoDefs.isSystemMessageDataMemberJoin, - ) - ) { - const newMember = log.relatedProfiles.find( - r => r.did === data.member.did, - ) - if (newMember) { - mutateList(list => - list.some(m => m.did === newMember.did) - ? list - : list.concat(newMember), - ) - } - } - } else if (ChatBskyConvoDefs.isLogMemberLeave(log)) { - const data = log.message.data - if ( - bsky.dangerousIsType( - data, - ChatBskyConvoDefs.isSystemMessageDataMemberLeave, - ) - ) { - mutateList(list => list.filter(m => m.did !== data.member.did)) - } - } - } - }, - {convoId}, - ) - return () => unsub() - }, [convoId, messagesBus, queryClient]) return useQuery({ queryKey: listConvoMembersQueryKey(convoId),