diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-03-04 13:54:19 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-03-04 05:54:19 -0800 |
commit | c995eb2f2fa3e73dcc6943078c85cd6a68f5370b (patch) | |
tree | 2dfea8ae6e4d86a77a90c72663b22441ca407159 /src/state/queries | |
parent | 5c14f695660dcbf815a584d9d3bb037171dd0c14 (diff) | |
download | voidsky-c995eb2f2fa3e73dcc6943078c85cd6a68f5370b.tar.zst |
DMs inbox (#7778)
* improve error screen * add chat request prompt * mock up inbox * bigger button * use two-button layout * get inbox working somewhat * fix type errors * fetch both pages for badge * don't include read convos in preview * in-chat ui for non-accepted convos (part 1) * add chatstatusinfo * fix status info not disappearing * get chat status info working * change min item height * move files around * add updated sdk * improve badge behaviour * mock up mark all as read * update sdk to 0.14.4 * hide chat status info if initiating convo * fix unread count for deleted accounts * add toasts after rejection * add prompt to delete * adjust badge on desktop * requests -> chat requests * fix height flicker * add mark as read button to header * add mark all as read APIs * separate avatarstack into two components (#7845) * fix messages being hidden behind chatstatusinfo * show inbox preview on empty state * fix empty state again * Use new convo availability API (#7812) * [Inbox] Accept button on convo screen (#7795) * accept button on convo screen * fix types * fix type error * improve spacing * [DMs] Implement new log types (#7835) * optimise badge state * add read message log * add isLogAcceptConvo * mute/unmute convo logs * use setqueriesdata * always show label on button * optimistically update badge * change incorrect unread count change * Update src/screens/Messages/Inbox.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestButtons.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Messages/components/RequestListItem.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * fix race condition with accepting convo * fix back button on web * filter left convos from badge * update atproto to fix CI * Add accept override external to convo (#7891) * Add accept override external to convo * rm log --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/messages/accept-conversation.ts | 135 | ||||
-rw-r--r-- | src/state/queries/messages/conversation.ts | 53 | ||||
-rw-r--r-- | src/state/queries/messages/get-convo-availability.ts | 25 | ||||
-rw-r--r-- | src/state/queries/messages/get-convo-for-members.ts | 35 | ||||
-rw-r--r-- | src/state/queries/messages/leave-conversation.ts | 18 | ||||
-rw-r--r-- | src/state/queries/messages/list-conversations.tsx | 421 | ||||
-rw-r--r-- | src/state/queries/messages/mute-conversation.ts | 4 | ||||
-rw-r--r-- | src/state/queries/messages/update-all-read.ts | 105 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 4 |
9 files changed, 608 insertions, 192 deletions
diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts new file mode 100644 index 000000000..82acb33c8 --- /dev/null +++ b/src/state/queries/messages/accept-conversation.ts @@ -0,0 +1,135 @@ +import {ChatBskyConvoAcceptConvo, ChatBskyConvoListConvos} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {DM_SERVICE_HEADERS} from './const' +import { + RQKEY as CONVO_LIST_KEY, + RQKEY_ROOT as CONVO_LIST_ROOT_KEY, +} from './list-conversations' + +export function useAcceptConversation( + convoId: string, + { + onSuccess, + onMutate, + onError, + }: { + onMutate?: () => void + onSuccess?: (data: ChatBskyConvoAcceptConvo.OutputSchema) => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + const {data} = await agent.chat.bsky.convo.acceptConvo( + {convoId}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + onMutate: () => { + let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let convoBeingAccepted: + | ChatBskyConvoListConvos.OutputSchema['convos'][number] + | undefined + queryClient.setQueryData( + CONVO_LIST_KEY('request'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + prevInboxPages = old.pages + return { + ...old, + pages: old.pages.map(page => { + const found = page.convos.find(convo => convo.id === convoId) + if (found) { + convoBeingAccepted = found + return { + ...page, + convos: page.convos.filter(convo => convo.id !== convoId), + } + } + return page + }), + } + }, + ) + queryClient.setQueryData( + CONVO_LIST_KEY('accepted'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + prevAcceptedPages = old.pages + if (convoBeingAccepted) { + return { + ...old, + pages: [ + { + ...old.pages[0], + convos: [ + { + ...convoBeingAccepted, + status: 'accepted', + }, + ...old.pages[0].convos, + ], + }, + ...old.pages.slice(1), + ], + } + } else { + return old + } + }, + ) + onMutate?.() + return {prevAcceptedPages, prevInboxPages} + }, + onSuccess: data => { + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + onSuccess?.(data) + }, + onError: (error, _, context) => { + logger.error(error) + queryClient.setQueryData( + CONVO_LIST_KEY('accepted'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevAcceptedPages || old.pages, + } + }, + ) + queryClient.setQueryData( + CONVO_LIST_KEY('request'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevInboxPages || old.pages, + } + }, + ) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index 260524524..de5a90571 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -13,7 +13,7 @@ import {useAgent} from '#/state/session' import { ConvoListQueryData, getConvoFromQueryData, - RQKEY as LIST_CONVOS_KEY, + RQKEY_ROOT as LIST_CONVOS_KEY, } from './list-conversations' const RQKEY_ROOT = 'convo' @@ -76,34 +76,37 @@ export function useMarkAsReadMutation() { onSuccess(_, {convoId}) { if (!convoId) return - queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => { - if (!old) return old + queryClient.setQueriesData( + {queryKey: [LIST_CONVOS_KEY]}, + (old?: ConvoListQueryData) => { + if (!old) return old - const existingConvo = getConvoFromQueryData(convoId, old) + const existingConvo = getConvoFromQueryData(convoId, old) - if (existingConvo) { - return { - ...old, - pages: old.pages.map(page => { - return { - ...page, - convos: page.convos.map(convo => { - if (convo.id === convoId) { - return { - ...convo, - unreadCount: 0, + if (existingConvo) { + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.map(convo => { + if (convo.id === convoId) { + return { + ...convo, + unreadCount: 0, + } } - } - return convo - }), - } - }), + return convo + }), + } + }), + } + } else { + // If we somehow marked a convo as read that doesn't exist in the + // list, then we don't need to do anything. } - } else { - // If we somehow marked a convo as read that doesn't exist in the - // list, then we don't need to do anything. - } - }) + }, + ) }, }) } diff --git a/src/state/queries/messages/get-convo-availability.ts b/src/state/queries/messages/get-convo-availability.ts new file mode 100644 index 000000000..f545c3bba --- /dev/null +++ b/src/state/queries/messages/get-convo-availability.ts @@ -0,0 +1,25 @@ +import {useQuery} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' +import {useAgent} from '#/state/session' +import {STALE} from '..' + +const RQKEY_ROOT = 'convo-availability' +export const RQKEY = (did: string) => [RQKEY_ROOT, did] + +export function useGetConvoAvailabilityQuery(did: string) { + const agent = useAgent() + + return useQuery({ + queryKey: RQKEY(did), + queryFn: async () => { + const {data} = await agent.chat.bsky.convo.getConvoAvailability( + {members: [did]}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + staleTime: STALE.INFINITY, + }) +} diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts index 7979e0665..3f45c2328 100644 --- a/src/state/queries/messages/get-convo-for-members.ts +++ b/src/state/queries/messages/get-convo-for-members.ts @@ -1,14 +1,10 @@ import {ChatBskyConvoGetConvoForMembers} from '@atproto/api' -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {logger} from '#/logger' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' -import {STALE} from '..' -import {RQKEY as CONVO_KEY} from './conversation' - -const RQKEY_ROOT = 'convo-for-user' -export const RQKEY = (did: string) => [RQKEY_ROOT, did] +import {precacheConvoQuery} from './conversation' export function useGetConvoForMembers({ onSuccess, @@ -22,7 +18,7 @@ export function useGetConvoForMembers({ return useMutation({ mutationFn: async (members: string[]) => { - const {data} = await agent.api.chat.bsky.convo.getConvoForMembers( + const {data} = await agent.chat.bsky.convo.getConvoForMembers( {members: members}, {headers: DM_SERVICE_HEADERS}, ) @@ -30,7 +26,7 @@ export function useGetConvoForMembers({ return data }, onSuccess: data => { - queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo) + precacheConvoQuery(queryClient, data.convo) onSuccess?.(data) }, onError: error => { @@ -39,26 +35,3 @@ export function useGetConvoForMembers({ }, }) } - -/** - * Gets the conversation ID for a given DID. Returns null if it's not possible to message them. - */ -export function useMaybeConvoForUser(did: string) { - const agent = useAgent() - - return useQuery({ - queryKey: RQKEY(did), - queryFn: async () => { - const convo = await agent.api.chat.bsky.convo - .getConvoForMembers({members: [did]}, {headers: DM_SERVICE_HEADERS}) - .catch(() => ({success: null})) - - if (convo.success) { - return convo.data.convo - } else { - return null - } - }, - staleTime: STALE.INFINITY, - }) -} diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts index 21cd1f18c..b17e515be 100644 --- a/src/state/queries/messages/leave-conversation.ts +++ b/src/state/queries/messages/leave-conversation.ts @@ -1,3 +1,4 @@ +import {useMemo} from 'react' import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api' import { useMutation, @@ -8,7 +9,7 @@ import { import {logger} from '#/logger' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' -import {RQKEY as CONVO_LIST_KEY} from './list-conversations' +import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' const RQKEY_ROOT = 'leave-convo' export function RQKEY(convoId: string | undefined) { @@ -35,7 +36,7 @@ export function useLeaveConvo( mutationFn: async () => { if (!convoId) throw new Error('No convoId provided') - const {data} = await agent.api.chat.bsky.convo.leaveConvo( + const {data} = await agent.chat.bsky.convo.leaveConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) @@ -45,7 +46,7 @@ export function useLeaveConvo( onMutate: () => { let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] queryClient.setQueryData( - CONVO_LIST_KEY, + [CONVO_LIST_KEY], (old?: { pageParams: Array<string | undefined> pages: Array<ChatBskyConvoListConvos.OutputSchema> @@ -67,13 +68,13 @@ export function useLeaveConvo( return {prevPages} }, onSuccess: data => { - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) onSuccess?.(data) }, onError: (error, _, context) => { logger.error(error) queryClient.setQueryData( - CONVO_LIST_KEY, + [CONVO_LIST_KEY], (old?: { pageParams: Array<string | undefined> pages: Array<ChatBskyConvoListConvos.OutputSchema> @@ -85,7 +86,7 @@ export function useLeaveConvo( } }, ) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY}) + queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) onError?.(error) }, }) @@ -105,5 +106,8 @@ export function useLeftConvos() { filters: {mutationKey: [RQKEY_ROOT], status: 'success'}, select: mutation => mutation.options.mutationKey?.[1] as string | undefined, }) - return [...pending, ...success].filter(id => id !== undefined) + return useMemo( + () => [...pending, ...success].filter(id => id !== undefined), + [pending, success], + ) } diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 8c9d6c429..f5fce6347 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -9,6 +9,7 @@ import { ChatBskyConvoDefs, ChatBskyConvoListConvos, moderateProfile, + ModerationOpts, } from '@atproto/api' import { InfiniteData, @@ -23,26 +24,39 @@ import {useMessagesEventBus} from '#/state/messages/events' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent, useSession} from '#/state/session' +import {useLeftConvos} from './leave-conversation' -export const RQKEY = ['convo-list'] +export const RQKEY_ROOT = 'convo-list' +export const RQKEY = ( + status: 'accepted' | 'request' | 'all', + readState: 'all' | 'unread' = 'all', +) => [RQKEY_ROOT, status, readState] type RQPageParam = string | undefined export function useListConvosQuery({ enabled, + status, + readState = 'all', }: { enabled?: boolean + status?: 'request' | 'accepted' + readState?: 'all' | 'unread' } = {}) { const agent = useAgent() return useInfiniteQuery({ enabled, - queryKey: RQKEY, + queryKey: RQKEY(status ?? 'all', readState), queryFn: async ({pageParam}) => { - const {data} = await agent.api.chat.bsky.convo.listConvos( - {cursor: pageParam, limit: 20}, + const {data} = await agent.chat.bsky.convo.listConvos( + { + limit: 20, + cursor: pageParam, + readState: readState === 'unread' ? 'unread' : undefined, + status, + }, {headers: DM_SERVICE_HEADERS}, ) - return data }, initialPageParam: undefined as RQPageParam, @@ -50,9 +64,10 @@ export function useListConvosQuery({ }) } -const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>( - null, -) +const ListConvosContext = createContext<{ + accepted: ChatBskyConvoDefs.ConvoView[] + request: ChatBskyConvoDefs.ConvoView[] +} | null>(null) export function useListConvos() { const ctx = useContext(ListConvosContext) @@ -62,12 +77,13 @@ export function useListConvos() { return ctx } +const empty = {accepted: [], request: []} export function ListConvosProvider({children}: {children: React.ReactNode}) { const {hasSession} = useSession() if (!hasSession) { return ( - <ListConvosContext.Provider value={[]}> + <ListConvosContext.Provider value={empty}> {children} </ListConvosContext.Provider> ) @@ -81,20 +97,23 @@ export function ListConvosProviderInner({ }: { children: React.ReactNode }) { - const {refetch, data} = useListConvosQuery() + const {refetch, data} = useListConvosQuery({readState: 'unread'}) const messagesBus = useMessagesEventBus() const queryClient = useQueryClient() const {currentConvoId} = useCurrentConvoId() const {currentAccount} = useSession() + const leftConvos = useLeftConvos() - const debouncedRefetch = useMemo( - () => - throttle(refetch, 500, { - leading: true, - trailing: true, - }), - [refetch], - ) + const debouncedRefetch = useMemo(() => { + const refetchAndInvalidate = () => { + refetch() + queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) + } + return throttle(refetchAndInvalidate, 500, { + leading: true, + trailing: true, + }) + }, [refetch, queryClient]) useEffect(() => { const unsub = messagesBus.on( @@ -105,69 +124,159 @@ export function ListConvosProviderInner({ if (ChatBskyConvoDefs.isLogBeginConvo(log)) { debouncedRefetch() } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => - optimisticDelete(log.convoId, old), + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), ) } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => { - if ( - (ChatBskyConvoDefs.isDeletedMessageView(log.message) || - ChatBskyConvoDefs.isMessageView(log.message)) && - (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) || - ChatBskyConvoDefs.isMessageView(convo.lastMessage)) - ) { - return log.message.id === convo.lastMessage.id - ? { - ...convo, - rev: log.rev, - lastMessage: log.message, - } - : convo - } else { - return convo - } - }), + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(log.convoId, old, convo => { + if ( + (ChatBskyConvoDefs.isDeletedMessageView(log.message) || + ChatBskyConvoDefs.isMessageView(log.message)) && + (ChatBskyConvoDefs.isDeletedMessageView( + convo.lastMessage, + ) || + ChatBskyConvoDefs.isMessageView(convo.lastMessage)) + ) { + return log.message.id === convo.lastMessage.id + ? { + ...convo, + rev: log.rev, + lastMessage: log.message, + } + : convo + } else { + return convo + } + }), ) } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { // Store in a new var to avoid TS errors due to closures. const logRef: ChatBskyConvoDefs.LogCreateMessage = log - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - if (!old) return old + // Get all matching queries + const queries = queryClient.getQueriesData<ConvoListQueryData>({ + queryKey: [RQKEY_ROOT], + }) - function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { - let unreadCount = convo.unreadCount - if (convo.id !== currentConvoId) { - if ( - ChatBskyConvoDefs.isMessageView(logRef.message) || - ChatBskyConvoDefs.isDeletedMessageView(logRef.message) - ) { - if (logRef.message.sender.did !== currentAccount?.did) { - unreadCount++ + // Check if convo exists in any query + let foundConvo: ChatBskyConvoDefs.ConvoView | null = null + for (const [_key, query] of queries) { + if (!query) continue + const convo = getConvoFromQueryData(logRef.convoId, query) + if (convo) { + foundConvo = convo + break + } + } + + if (!foundConvo) { + // Convo not found, trigger refetch + debouncedRefetch() + return + } + + // Update the convo + const updatedConvo = { + ...foundConvo, + rev: logRef.rev, + lastMessage: logRef.message, + unreadCount: + foundConvo.id !== currentConvoId + ? (ChatBskyConvoDefs.isMessageView(logRef.message) || + ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) && + logRef.message.sender.did !== currentAccount?.did + ? foundConvo.unreadCount + 1 + : foundConvo.unreadCount + : 0, + } + + function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) { + return convo.filter(c => c.id !== logRef.convoId) + } + + // Update all matching queries + function updateFn(old?: ConvoListQueryData) { + if (!old) return old + return { + ...old, + pages: old.pages.map((page, i) => { + if (i === 0) { + return { + ...page, + convos: [ + updatedConvo, + ...filterConvoFromPage(page.convos), + ], } } - } else { - unreadCount = 0 - } - - return { + return { + ...page, + convos: filterConvoFromPage(page.convos), + } + }), + } + } + // always update the unread one + queryClient.setQueriesData( + {queryKey: RQKEY('all', 'unread')}, + (old?: ConvoListQueryData) => + old + ? updateFn(old) + : ({ + pageParams: [undefined], + pages: [{convos: [updatedConvo], cursor: undefined}], + } satisfies ConvoListQueryData), + ) + // update the other ones based on status of the incoming message + if (updatedConvo.status === 'accepted') { + queryClient.setQueriesData( + {queryKey: RQKEY('accepted')}, + updateFn, + ) + } else if (updatedConvo.status === 'request') { + queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) + } + } else if (ChatBskyConvoDefs.isLogReadMessage(log)) { + const logRef: ChatBskyConvoDefs.LogReadMessage = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ ...convo, + unreadCount: 0, rev: logRef.rev, - lastMessage: logRef.message, - unreadCount, + })), + ) + } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { + const logRef: ChatBskyConvoDefs.LogAcceptConvo = log + const requests = queryClient.getQueryData<ConvoListQueryData>( + RQKEY('request'), + ) + if (!requests) { + debouncedRefetch() + return + } + const acceptedConvo = getConvoFromQueryData(log.convoId, requests) + if (!acceptedConvo) { + debouncedRefetch() + return + } + queryClient.setQueryData( + RQKEY('request'), + (old?: ConvoListQueryData) => + optimisticDelete(logRef.convoId, old), + ) + queryClient.setQueriesData( + {queryKey: RQKEY('accepted')}, + (old?: ConvoListQueryData) => { + if (!old) { + debouncedRefetch() + return old } - } - - function filterConvoFromPage( - convo: ChatBskyConvoDefs.ConvoView[], - ) { - return convo.filter(c => c.id !== logRef.convoId) - } - - const existingConvo = getConvoFromQueryData(logRef.convoId, old) - - if (existingConvo) { return { ...old, pages: old.pages.map((page, i) => { @@ -175,26 +284,38 @@ export function ListConvosProviderInner({ return { ...page, convos: [ - updateConvo(existingConvo), - ...filterConvoFromPage(page.convos), + {...acceptedConvo, status: 'accepted'}, + ...page.convos, ], } } - return { - ...page, - convos: filterConvoFromPage(page.convos), - } + return page }), } - } else { - /** - * We received a message from an conversation old enough that - * it doesn't exist in the query cache, meaning we need to - * refetch and bump the old convo to the top. - */ - debouncedRefetch() - } - }) + }, + ) + } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { + const logRef: ChatBskyConvoDefs.LogMuteConvo = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ + ...convo, + muted: true, + rev: logRef.rev, + })), + ) + } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { + const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => + optimisticUpdate(logRef.convoId, old, convo => ({ + ...convo, + muted: false, + rev: logRef.rev, + })), + ) } } }, @@ -208,15 +329,21 @@ export function ListConvosProviderInner({ }, [ messagesBus, currentConvoId, - refetch, queryClient, currentAccount?.did, debouncedRefetch, ]) const ctx = useMemo(() => { - return data?.pages.flatMap(page => page.convos) ?? [] - }, [data]) + const convos = + data?.pages + .flatMap(page => page.convos) + .filter(convo => !leftConvos.includes(convo.id)) ?? [] + return { + accepted: convos.filter(conv => conv.status === 'accepted'), + request: convos.filter(conv => conv.status === 'request'), + } + }, [data, leftConvos]) return ( <ListConvosContext.Provider value={ctx}> @@ -228,38 +355,76 @@ export function ListConvosProviderInner({ export function useUnreadMessageCount() { const {currentConvoId} = useCurrentConvoId() const {currentAccount} = useSession() - const convos = useListConvos() + const {accepted, request} = useListConvos() const moderationOpts = useModerationOpts() - const count = useMemo(() => { - return ( - convos - .filter(convo => convo.id !== currentConvoId) - .reduce((acc, convo) => { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherMember || !moderationOpts) return acc - - const moderation = moderateProfile(otherMember, moderationOpts) - const shouldIgnore = - convo.muted || - moderation.blocked || - otherMember.did === 'missing.invalid' - const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 - - return acc + unreadCount - }, 0) ?? 0 + return useMemo<{ + count: number + numUnread?: string + hasNew: boolean + }>(() => { + const acceptedCount = calculateCount( + accepted, + currentAccount?.did, + currentConvoId, + moderationOpts, ) - }, [convos, currentAccount?.did, currentConvoId, moderationOpts]) - - return useMemo(() => { - return { - count, - numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined, + const requestCount = calculateCount( + request, + currentAccount?.did, + currentConvoId, + moderationOpts, + ) + if (acceptedCount > 0) { + const total = acceptedCount + Math.min(requestCount, 1) + return { + count: total, + numUnread: total > 10 ? '10+' : String(total), + // only needed when numUnread is undefined + hasNew: false, + } + } else if (requestCount > 0) { + return { + count: 1, + numUnread: undefined, + hasNew: true, + } + } else { + return { + count: 0, + numUnread: undefined, + hasNew: false, + } } - }, [count]) + }, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts]) +} + +function calculateCount( + convos: ChatBskyConvoDefs.ConvoView[], + currentAccountDid: string | undefined, + currentConvoId: string | undefined, + moderationOpts: ModerationOpts | undefined, +) { + return ( + convos + .filter(convo => convo.id !== currentConvoId) + .reduce((acc, convo) => { + const otherMember = convo.members.find( + member => member.did !== currentAccountDid, + ) + + if (!otherMember || !moderationOpts) return acc + + const moderation = moderateProfile(otherMember, moderationOpts) + const shouldIgnore = + convo.muted || + moderation.blocked || + otherMember.handle === 'missing.invalid' + const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 + + return acc + unreadCount + }, 0) ?? 0 + ) } export type ConvoListQueryData = { @@ -272,12 +437,16 @@ export function useOnMarkAsRead() { return useCallback( (chatId: string) => { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - return optimisticUpdate(chatId, old, convo => ({ - ...convo, - unreadCount: 0, - })) - }) + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + (old?: ConvoListQueryData) => { + if (!old) return old + return optimisticUpdate(chatId, old, convo => ({ + ...convo, + unreadCount: 0, + })) + }, + ) }, [queryClient], ) @@ -285,10 +454,12 @@ export function useOnMarkAsRead() { function optimisticUpdate( chatId: string, - old: ConvoListQueryData, - updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, + old?: ConvoListQueryData, + updateFn?: ( + convo: ChatBskyConvoDefs.ConvoView, + ) => ChatBskyConvoDefs.ConvoView, ) { - if (!old) return old + if (!old || !updateFn) return old return { ...old, @@ -301,7 +472,7 @@ function optimisticUpdate( } } -function optimisticDelete(chatId: string, old: ConvoListQueryData) { +function optimisticDelete(chatId: string, old?: ConvoListQueryData) { if (!old) return old return { @@ -331,7 +502,7 @@ export function* findAllProfilesInQueryData( const queryDatas = queryClient.getQueriesData< InfiniteData<ChatBskyConvoListConvos.OutputSchema> >({ - queryKey: RQKEY, + queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData?.pages) { diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts index f32d02229..da9644145 100644 --- a/src/state/queries/messages/mute-conversation.ts +++ b/src/state/queries/messages/mute-conversation.ts @@ -8,7 +8,7 @@ import {InfiniteData, useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent} from '#/state/session' import {RQKEY as CONVO_KEY} from './conversation' -import {RQKEY as CONVO_LIST_KEY} from './list-conversations' +import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' export function useMuteConvo( convoId: string | undefined, @@ -53,7 +53,7 @@ export function useMuteConvo( ) queryClient.setQueryData< InfiniteData<ChatBskyConvoListConvos.OutputSchema> - >(CONVO_LIST_KEY, prev => { + >([CONVO_LIST_KEY], prev => { if (!prev?.pages) return return { ...prev, diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts new file mode 100644 index 000000000..72fa65ee6 --- /dev/null +++ b/src/state/queries/messages/update-all-read.ts @@ -0,0 +1,105 @@ +import {ChatBskyConvoListConvos} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' +import {useAgent} from '#/state/session' +import {RQKEY as CONVO_LIST_KEY} from './list-conversations' + +export function useUpdateAllRead( + status: 'accepted' | 'request', + { + onSuccess, + onMutate, + onError, + }: { + onMutate?: () => void + onSuccess?: () => void + onError?: (error: Error) => void + }, +) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async () => { + const {data} = await agent.chat.bsky.convo.updateAllRead( + {status}, + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, + ) + + return data + }, + onMutate: () => { + let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] + queryClient.setQueryData( + CONVO_LIST_KEY(status), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + prevPages = old.pages + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.map(convo => { + return { + ...convo, + unreadCount: 0, + } + }), + } + }), + } + }, + ) + // remove unread convos from the badge query + queryClient.setQueryData( + CONVO_LIST_KEY('all', 'unread'), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + return { + ...old, + pages: old.pages.map(page => { + return { + ...page, + convos: page.convos.filter(convo => convo.status !== status), + } + }), + } + }, + ) + onMutate?.() + return {prevPages} + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + onSuccess?.() + }, + onError: (error, _, context) => { + logger.error(error) + queryClient.setQueryData( + CONVO_LIST_KEY(status), + (old?: { + pageParams: Array<string | undefined> + pages: Array<ChatBskyConvoListConvos.OutputSchema> + }) => { + if (!old) return old + return { + ...old, + pages: context?.prevPages || old.pages, + } + }, + ) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')}) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 2c98df634..227ca9d66 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -37,7 +37,7 @@ import { ProgressGuideAction, useProgressGuideControls, } from '../shell/progress-guide' -import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-conversations' +import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' @@ -456,7 +456,7 @@ export function useProfileBlockMutationQueue( updateProfileShadow(queryClient, did, { blockingUri: finalBlockingUri, }) - queryClient.invalidateQueries({queryKey: RQKEY_LIST_CONVOS}) + queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) }, }) |