diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5bd3357d19..304fd4a065 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1773,16 +1773,6 @@ "count": 7 } }, - "src/state/queries/messages/accept-conversation.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 2 - } - }, - "src/state/queries/messages/update-all-read.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 3 - } - }, "src/state/queries/my-lists.ts": { "@typescript-eslint/no-floating-promises": { "count": 2 diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index c49dada16f..2f3fb8b15d 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -36,7 +36,7 @@ export function AvatarBubbles({ moderationOpts, }: { animate?: boolean - profiles: bsky.profile.AnyProfileView[] + profiles: (bsky.profile.AnyProfileView | undefined)[] /** * By default, when there are more than 2 profiles, the current user is * filtered out (so you don't see yourself among your own group's members). @@ -50,12 +50,12 @@ export function AvatarBubbles({ const {currentAccount} = useSession() const profiles = !self && allProfiles.length > 2 - ? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did) + ? allProfiles.filter(p => !p || p.did !== currentAccount?.did) : allProfiles const moderations = useMemo(() => { if (!moderationOpts) return [] return profiles.map(p => { - return moderateProfile(p, moderationOpts) + return p && moderateProfile(p, moderationOpts) }) }, [profiles, moderationOpts]) diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx index 59ca05bdba..60891c3c11 100644 --- a/src/screens/Messages/Inbox.tsx +++ b/src/screens/Messages/Inbox.tsx @@ -1,8 +1,9 @@ import {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' import { - type ChatBskyConvoDefs, - type ChatBskyConvoListConvos, + ChatBskyConvoDefs, + type ChatBskyConvoListConvoRequests, + ChatBskyGroupDefs, } from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useFocusEffect, useNavigation} from '@react-navigation/native' @@ -23,7 +24,7 @@ import {logger} from '#/logger' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {useMessagesEventBus} from '#/state/messages/events' import {useLeftConvos} from '#/state/queries/messages/leave-conversation' -import {useListConvosQuery} from '#/state/queries/messages/list-conversations' +import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {EmptyState} from '#/view/com/util/EmptyState' import {List} from '#/view/com/util/List' @@ -43,11 +44,16 @@ import {ListFooter} from '#/components/Lists' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' -import {RequestListItem} from './components/RequestListItem' +import {IncomingRequestListItem} from './components/IncomingRequestListItem' +import {OutgoingRequestListItem} from './components/OutgoingRequestListItem' import {useIsWithinSplitView} from './components/splitView/context' type Props = NativeStackScreenProps +type RequestItem = + | {type: 'incoming'; view: ChatBskyConvoDefs.ConvoView} + | {type: 'outgoing'; view: ChatBskyGroupDefs.JoinRequestConvoView} + export function MessagesInboxScreen(props: Props) { const {t: l} = useLingui() const aaCopy = useAgeAssuranceCopy() @@ -61,29 +67,36 @@ export function MessagesInboxScreen(props: Props) { } export function MessagesInboxScreenInner({}: Props) { - const listConvosQuery = useListConvosQuery({status: 'request'}) + const listConvosQuery = useListConvoRequests() const {data} = listConvosQuery const leftConvos = useLeftConvos() - const conversations = useMemo(() => { - if (data?.pages) { - const convos = data.pages - .flatMap(page => page.convos) - // filter out convos that are actively being left - .filter(convo => !leftConvos.includes(convo.id)) - - return convos + const conversations = useMemo(() => { + if (!data?.pages) return [] + const items: RequestItem[] = [] + for (const page of data.pages) { + for (const item of page.requests) { + if (ChatBskyConvoDefs.isConvoView(item)) { + // filter out convos that are actively being left + if (leftConvos.includes(item.id)) continue + items.push({type: 'incoming', view: item}) + } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) { + items.push({type: 'outgoing', view: item}) + } + } } - return [] + return items }, [data, leftConvos]) const hasUnreadConvos = useMemo(() => { return conversations.some( - conversation => - conversation.members.every( + item => + item.type === 'incoming' && + item.view.members.every( member => member.handle !== 'missing.invalid', - ) && conversation.unreadCount > 0, + ) && + item.view.unreadCount > 0, ) }, [conversations]) @@ -111,10 +124,10 @@ function RequestList({ conversations, }: { listConvosQuery: UseInfiniteQueryResult< - InfiniteData, + InfiniteData, Error > - conversations: ChatBskyConvoDefs.ConvoView[] + conversations: RequestItem[] }) { const {t: l} = useLingui() const t = useTheme() @@ -274,17 +287,21 @@ function RequestList({ windowSize={11} desktopFixedHeight sideBorders={false} + contentContainerStyle={[web(a.py_sm)]} /> ) } -function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { - return item.id +function keyExtractor(item: RequestItem) { + return item.type === 'incoming' ? item.view.id : item.view.convoId } -function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { - return +function renderItem({item}: {item: RequestItem}) { + if (item.type === 'incoming') { + return + } + return } function MarkAsReadHeaderButton() { diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/IncomingRequestListItem.tsx similarity index 98% rename from src/screens/Messages/components/RequestListItem.tsx rename to src/screens/Messages/components/IncomingRequestListItem.tsx index 79eab480ae..2ddc8d1997 100644 --- a/src/screens/Messages/components/RequestListItem.tsx +++ b/src/screens/Messages/components/IncomingRequestListItem.tsx @@ -11,7 +11,7 @@ import {Text} from '#/components/Typography' import {ChatListItem, ChatListItemPortal} from './ChatListItem' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' -export function RequestListItem({ +export function IncomingRequestListItem({ convo: convoView, }: { convo: ChatBskyConvoDefs.ConvoView diff --git a/src/screens/Messages/components/OutgoingRequestListItem.tsx b/src/screens/Messages/components/OutgoingRequestListItem.tsx new file mode 100644 index 0000000000..8bd7ba78d0 --- /dev/null +++ b/src/screens/Messages/components/OutgoingRequestListItem.tsx @@ -0,0 +1,128 @@ +import {View} from 'react-native' +import { + type ChatBskyGroupDefs, + ChatBskyGroupWithdrawJoinRequest, +} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {isNetworkError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' +import {atoms as a, useTheme, web} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {createStaticClick, Link} from '#/components/Link' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' + +export function OutgoingRequestListItem({ + convo: convoView, +}: { + convo: ChatBskyGroupDefs.JoinRequestConvoView +}) { + const t = useTheme() + const {t: l} = useLingui() + + const prompt = Prompt.usePromptControl() + + const moderationOpts = useModerationOpts() + + const {mutate: withdrawRequest, isPending: isWithdrawPending} = + useWithdrawJoinGroupChatRequest({ + onSuccess: () => { + Toast.show(l`Join request rescinded.`) + }, + onError: error => { + let errorMessage = l`Failed to rescind your request. Please try again.` + if (isNetworkError(error)) { + errorMessage = l`There was a problem with your internet connection, please try again` + } else if ( + error instanceof + ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError + ) { + errorMessage = l`Invalid rescind request.` + } + Toast.show(errorMessage) + }, + }) + + return ( + <> + { + prompt.open() + })}> + {({hovered, pressed, focused}) => ( + + + + + + + {convoView.name} + + + + + {({timeElapsed}) => ( + + {timeElapsed} + + )} + + + + + + You requested to join + + + + + )} + + { + prompt.close(() => { + if (isWithdrawPending) return + withdrawRequest({convoId: convoView.convoId}) + }) + }} + /> + + ) +} diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 8b61b4416e..f387306f4e 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInGetConvoQueryData} from '#/state/queries/messages/conversation' +import {findAllProfilesInQueryData as findAllProfilesInListConvoRequestsQueryData} from '#/state/queries/messages/list-conversation-requests' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' @@ -259,6 +260,7 @@ function* findProfilesInCache( yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) yield* findAllProfilesInActorSearchQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did) + yield* findAllProfilesInListConvoRequestsQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts index 0c06055b55..6f1b3d272b 100644 --- a/src/state/queries/messages/accept-conversation.ts +++ b/src/state/queries/messages/accept-conversation.ts @@ -7,6 +7,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import { + type ConvoRequestListQueryData, + optimisticDelete as optimisticDeleteRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import { RQKEY as CONVO_LIST_KEY, RQKEY_ROOT as CONVO_LIST_ROOT_KEY, @@ -96,11 +101,20 @@ export function useAcceptConversation( } }, ) + const prevRequestsQueries = + queryClient.getQueriesData({ + queryKey: [REQUESTS_RQKEY_ROOT], + }) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(convoId, old), + ) onMutate?.() - return {prevAcceptedPages, prevInboxPages} + return {prevAcceptedPages, prevInboxPages, prevRequestsQueries} }, onSuccess: data => { - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onSuccess?.(data) }, onError: (error, _, context) => { @@ -131,7 +145,13 @@ export function useAcceptConversation( } }, ) - queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + if (context?.prevRequestsQueries) { + for (const [queryKey, prevData] of context.prevRequestsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onError?.(error) }, }) diff --git a/src/state/queries/messages/list-conversation-requests.tsx b/src/state/queries/messages/list-conversation-requests.tsx new file mode 100644 index 0000000000..10023db4eb --- /dev/null +++ b/src/state/queries/messages/list-conversation-requests.tsx @@ -0,0 +1,165 @@ +import { + ChatBskyConvoDefs, + type ChatBskyConvoListConvoRequests, + ChatBskyGroupDefs, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {useAgent} from '#/state/session' + +const DEFAULT_LIMIT = 10 + +export const RQKEY_ROOT = 'convo-request-list' +export const RQKEY = (limit: number = DEFAULT_LIMIT) => [RQKEY_ROOT, limit] + +type RQPageParam = string | undefined + +export function useListConvoRequests({ + enabled = true, + limit = DEFAULT_LIMIT, +}: { + enabled?: boolean + limit?: number +} = {}) { + const agent = useAgent() + + return useInfiniteQuery({ + enabled, + queryKey: RQKEY(limit), + queryFn: async ({pageParam}) => { + const {data} = await agent.chat.bsky.convo.listConvoRequests( + {limit, cursor: pageParam}, + {headers: DM_SERVICE_HEADERS}, + ) + return data + }, + initialPageParam: undefined as RQPageParam, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export type ConvoRequestListQueryData = { + pageParams: Array + pages: Array +} + +export type ConvoRequestItem = + ChatBskyConvoListConvoRequests.OutputSchema['requests'][number] + +export function optimisticUpdate( + chatId: string, + old: ConvoRequestListQueryData | undefined, + updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, +): ConvoRequestListQueryData | undefined { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.map((item): ConvoRequestItem => { + if (ChatBskyConvoDefs.isConvoView(item) && item.id === chatId) { + return { + ...updateFn(item), + $type: 'chat.bsky.convo.defs#convoView', + } + } + return item + }), + })), + } +} + +export function optimisticDelete( + chatId: string, + old: ConvoRequestListQueryData | undefined, +) { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.filter( + item => !ChatBskyConvoDefs.isConvoView(item) || item.id !== chatId, + ), + })), + } +} + +export function markAllRead( + old: ConvoRequestListQueryData | undefined, +): ConvoRequestListQueryData | undefined { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.map((item): ConvoRequestItem => { + if (ChatBskyConvoDefs.isConvoView(item)) { + return { + ...item, + $type: 'chat.bsky.convo.defs#convoView', + unreadCount: 0, + } + } + return item + }), + })), + } +} + +export function optimisticDeleteJoinRequest( + convoId: string, + old: ConvoRequestListQueryData | undefined, +) { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + requests: page.requests.filter( + item => + !ChatBskyGroupDefs.isJoinRequestConvoView(item) || + item.convoId !== convoId, + ), + })), + } +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +) { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) continue + + for (const page of queryData.pages) { + for (const item of page.requests) { + if (ChatBskyConvoDefs.isConvoView(item)) { + for (const member of item.members) { + if (member.did === did) { + yield member + } + } + } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) { + if (item.owner.did === did) { + yield item.owner + } + } + } + } + } +} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index dadf30bcdf..372ef708f3 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -23,6 +23,13 @@ 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 { + type ConvoRequestListQueryData, + optimisticDelete as optimisticDeleteRequest, + optimisticDeleteJoinRequest, + optimisticUpdate as optimisticUpdateRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import {listConvoMembersQueryKey} from './list-convo-members' const DEFAULT_LIMIT = 10 @@ -130,6 +137,7 @@ export function ListConvosProviderInner({ const refetchAndInvalidate = () => { void refetch() void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) } return throttle(refetchAndInvalidate, 500, { leading: true, @@ -157,6 +165,22 @@ export function ListConvosProviderInner({ ) } + function updateConvoInAllLists( + convoId: string, + fn: ( + convo: ChatBskyConvoDefs.ConvoView, + ) => ChatBskyConvoDefs.ConvoView, + ) { + queryClient.setQueriesData( + {queryKey: [RQKEY_ROOT]}, + old => optimisticUpdate(convoId, old, fn), + ) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticUpdateRequest(convoId, old, fn), + ) + } + function mutateConvoView( convoId: string, fn: ( @@ -167,9 +191,17 @@ export function ListConvosProviderInner({ CONVO_KEY(convoId), old => (old ? fn(old) : old), ) + updateConvoInAllLists(convoId, fn) + } + + function deleteConvoFromAllLists(convoId: string) { queryClient.setQueriesData( {queryKey: [RQKEY_ROOT]}, - old => optimisticUpdate(convoId, old, fn), + old => optimisticDelete(convoId, old), + ) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(convoId, old), ) } @@ -220,35 +252,26 @@ export function ListConvosProviderInner({ if (ChatBskyConvoDefs.isLogBeginConvo(log)) { debouncedRefetch() } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), - ) + deleteConvoFromAllLists(log.convoId) } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { - 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 - } - }), - ) + updateConvoInAllLists(log.convoId, 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 @@ -335,27 +358,24 @@ export function ListConvosProviderInner({ ) } else if (updatedConvo.status === 'request') { queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn) + // also move-to-top in the new requests cache + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => moveConvoToTopInRequests(updatedConvo, old), + ) } } else if (ChatBskyConvoDefs.isLogReadMessage(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - unreadCount: 0, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + unreadCount: 0, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogReadConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - unreadCount: 0, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + unreadCount: 0, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { const requests = queryClient.getQueryData( RQKEY('request'), @@ -373,6 +393,11 @@ export function ListConvosProviderInner({ RQKEY('request'), (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old), ) + // also remove from the new requests cache + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteRequest(log.convoId, old), + ) queryClient.setQueriesData( {queryKey: RQKEY('accepted')}, (old?: ConvoListQueryData) => { @@ -398,55 +423,50 @@ export function ListConvosProviderInner({ }, ) } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - muted: true, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + muted: true, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - muted: false, - rev: log.rev, - })), - ) + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + muted: false, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogLockConvo(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'locked'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'locked'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'unlocked'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'unlocked'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) { - mutateConvoView(log.convoId, convo => - ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? { - ...convo, - kind: {...convo.kind, lockStatus: 'locked-permanently'}, - rev: log.rev, - } - : {...convo, rev: log.rev}, - ) + mutateConvoView(log.convoId, convo => { + if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return { + ...convo, + kind: {...convo.kind, lockStatus: 'locked-permanently'}, + rev: log.rev, + } + } + return {...convo, rev: log.rev} + }) } else if ( ChatBskyConvoDefs.isLogCreateJoinLink(log) || ChatBskyConvoDefs.isLogEditJoinLink(log) || @@ -455,37 +475,68 @@ export function ListConvosProviderInner({ ) { // Join link data not included in the log event, trigger refetch to get it debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogEditGroup(log)) { + // Updated group details (name etc.) aren't included in the log + // event, so refetch to pick them up. + debouncedRefetch() } else if ( ChatBskyConvoDefs.isLogApproveJoinRequest(log) || ChatBskyConvoDefs.isLogRejectJoinRequest(log) ) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - updateGroupConvoJoinRequestCount(log, old, -1), + // Route through mutateConvoView (not updateConvoInAllLists) so the + // single-convo cache updates too, keeping the in-convo requests + // banner in sync. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, -1), ) } else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - updateGroupConvoJoinRequestCount(log, old, 1), + // Route through mutateConvoView (not updateConvoInAllLists) so the + // single-convo cache updates too, letting the in-convo requests + // banner appear live. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, 1), ) + } else if (ChatBskyConvoDefs.isLogReadJoinRequests(log)) { + // The owner marked join requests as read (possibly on another + // device). Zero the unread count but keep the total, mirroring the + // useMarkJoinRequestsRead mutation. + mutateConvoView(log.convoId, convo => { + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return {...convo, rev: log.rev} + } + return { + ...convo, + kind: {...convo.kind, unreadJoinRequestCount: 0}, + rev: log.rev, + } + }) } else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) { - // Viewer isn't in the chat yet, no need to do anything - } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { - queryClient.setQueriesData( - {queryKey: [RQKEY_ROOT]}, - (old?: ConvoListQueryData) => - optimisticUpdate(log.convoId, old, convo => ({ - ...convo, - lastReaction: { - $type: 'chat.bsky.convo.defs#messageAndReactionView', - reaction: log.reaction, - message: log.message, - }, - rev: log.rev, - })), + // Viewer isn't in the chat yet, but the inbox surfaces outgoing + // requests, so refetch to pick up the new entry. + debouncedRefetch() + } else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) { + // A requester rescinded their request to a group the viewer owns. + // Mirror of isLogIncomingJoinRequest: decrement the counts. + mutateConvoView(log.convoId, convo => + applyJoinRequestCountDelta(convo, log.rev, -1), ) + } else if (ChatBskyConvoDefs.isLogWithdrawOutgoingJoinRequest(log)) { + // The viewer rescinded their own outgoing join request (possibly on + // another device). Remove it from the requests inbox cache. + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteJoinRequest(log.convoId, old), + ) + } else if (ChatBskyConvoDefs.isLogAddReaction(log)) { + updateConvoInAllLists(log.convoId, convo => ({ + ...convo, + lastReaction: { + $type: 'chat.bsky.convo.defs#messageAndReactionView', + reaction: log.reaction, + message: log.message, + }, + rev: log.rev, + })) } else if (ChatBskyConvoDefs.isLogAddMember(log)) { const data = log.message.data if ( @@ -739,27 +790,58 @@ function optimisticUpdate( } } -function updateGroupConvoJoinRequestCount( - log: {convoId: string; rev: string}, - old: ConvoListQueryData | undefined, +function applyJoinRequestCountDelta( + convo: ChatBskyConvoDefs.ConvoView, + rev: string, delta: 1 | -1, -) { - return optimisticUpdate(log.convoId, old, convo => { - // Join requests are only meaningful for group convos. - if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { - return {...convo, rev: log.rev} +): ChatBskyConvoDefs.ConvoView { + // Join requests are only meaningful for group convos. + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) { + return {...convo, rev} + } + // Bump the total and unread counts together. Both are clamped at 0 and + // collapse to undefined when empty, matching the server's shape. + const bump = (current: number | undefined) => { + const next = Math.max(0, (current ?? 0) + delta) + return next === 0 ? undefined : next + } + return { + ...convo, + kind: { + ...convo.kind, + joinRequestCount: bump(convo.kind.joinRequestCount), + unreadJoinRequestCount: bump(convo.kind.unreadJoinRequestCount), + }, + rev, + } +} + +function moveConvoToTopInRequests( + updatedConvo: ChatBskyConvoDefs.ConvoView, + old: ConvoRequestListQueryData | undefined, +): ConvoRequestListQueryData | undefined { + if (!old) return old + const typedConvo: ConvoRequestListQueryData['pages'][number]['requests'][number] = + { + $type: 'chat.bsky.convo.defs#convoView', + ...updatedConvo, } - const current = convo.kind.joinRequestCount ?? 0 - const next = Math.max(0, current + delta) - return { - ...convo, - kind: { - ...convo.kind, - joinRequestCount: next === 0 ? undefined : next, - }, - rev: log.rev, - } - }) + return { + ...old, + pages: old.pages.map((page, i) => { + const filtered = page.requests.filter( + item => + !ChatBskyConvoDefs.isConvoView(item) || item.id !== updatedConvo.id, + ) + if (i === 0) { + return { + ...page, + requests: [typedConvo, ...filtered], + } + } + return {...page, requests: filtered} + }), + } } function removeMemberFromConvoView( diff --git a/src/state/queries/messages/request-join-group-chat.ts b/src/state/queries/messages/request-join-group-chat.ts index 04e63395f7..f4d5650346 100644 --- a/src/state/queries/messages/request-join-group-chat.ts +++ b/src/state/queries/messages/request-join-group-chat.ts @@ -1,9 +1,10 @@ import {type ChatBskyGroupRequestJoin} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' +import {RQKEY_ROOT as REQUESTS_RQKEY_ROOT} from './list-conversation-requests' export function useRequestJoinGroupChat({ onSuccess, @@ -13,6 +14,7 @@ export function useRequestJoinGroupChat({ onError?: (error: Error) => void } = {}) { const agent = useAgent() + const queryClient = useQueryClient() const {hasSession} = useSession() return useMutation({ @@ -27,6 +29,7 @@ export function useRequestJoinGroupChat({ return res.data }, onSuccess: data => { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) onSuccess?.(data) }, onError: error => { diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts index 3d0fd3a45e..79173362dc 100644 --- a/src/state/queries/messages/update-all-read.ts +++ b/src/state/queries/messages/update-all-read.ts @@ -4,6 +4,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import { + type ConvoRequestListQueryData, + markAllRead as markAllRequestsRead, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' import {RQKEY as CONVO_LIST_KEY} from './list-conversations' export function useUpdateAllRead( @@ -32,6 +37,9 @@ export function useUpdateAllRead( }, onMutate: () => { let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] + let prevRequestsQueries: Array< + [readonly unknown[], ConvoRequestListQueryData | undefined] + > = [] queryClient.setQueryData( CONVO_LIST_KEY(status), (old?: { @@ -75,11 +83,24 @@ export function useUpdateAllRead( } }, ) + if (status === 'request') { + prevRequestsQueries = + queryClient.getQueriesData({ + queryKey: [REQUESTS_RQKEY_ROOT], + }) + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + markAllRequestsRead, + ) + } onMutate?.() - return {prevPages} + return {prevPages, prevRequestsQueries} }, onSuccess: () => { - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + if (status === 'request') { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) + } onSuccess?.() }, onError: (error, _, context) => { @@ -97,8 +118,18 @@ export function useUpdateAllRead( } }, ) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) - queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')}) + if (status === 'request' && context?.prevRequestsQueries) { + for (const [queryKey, prevData] of context.prevRequestsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)}) + void queryClient.invalidateQueries({ + queryKey: CONVO_LIST_KEY('all', 'unread'), + }) + if (status === 'request') { + void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) + } onError?.(error) }, }) diff --git a/src/state/queries/messages/withdraw-join-group-chat.ts b/src/state/queries/messages/withdraw-join-group-chat.ts index 92025cc2be..2ede8c941d 100644 --- a/src/state/queries/messages/withdraw-join-group-chat.ts +++ b/src/state/queries/messages/withdraw-join-group-chat.ts @@ -1,9 +1,14 @@ import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api' -import {useMutation} from '@tanstack/react-query' +import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent, useSession} from '#/state/session' +import { + type ConvoRequestListQueryData, + optimisticDeleteJoinRequest, + RQKEY_ROOT as REQUESTS_RQKEY_ROOT, +} from './list-conversation-requests' export function useWithdrawJoinGroupChatRequest({ onSuccess, @@ -13,6 +18,7 @@ export function useWithdrawJoinGroupChatRequest({ onError?: (error: Error) => void } = {}) { const agent = useAgent() + const queryClient = useQueryClient() const {hasSession} = useSession() return useMutation({ @@ -27,7 +33,11 @@ export function useWithdrawJoinGroupChatRequest({ ) return res.data }, - onSuccess: data => { + onSuccess: (data, {convoId}) => { + queryClient.setQueriesData( + {queryKey: [REQUESTS_RQKEY_ROOT]}, + old => optimisticDeleteJoinRequest(convoId, old), + ) onSuccess?.(data) }, onError: error => {