diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx index 7145e5d88f..60538615a1 100644 --- a/src/state/messages/index.tsx +++ b/src/state/messages/index.tsx @@ -6,6 +6,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id' import {MessagesEventBusProvider} from '#/state/messages/events' +import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' import {useAgent} from '#/state/session' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' @@ -37,15 +38,18 @@ export function ChatProvider({ }), ) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) + const {mutate: markAsRead} = useMarkAsReadMutation() useFocusEffect( React.useCallback(() => { convo.resume() + markAsRead({convoId}) return () => { convo.background() + markAsRead({convoId}) } - }, [convo]), + }, [convo, convoId, markAsRead]), ) React.useEffect(() => { @@ -56,6 +60,8 @@ export function ChatProvider({ } else { convo.background() } + + markAsRead({convoId}) } } @@ -64,7 +70,7 @@ export function ChatProvider({ return () => { sub.remove() } - }, [convo, isScreenFocused]) + }, [convoId, convo, isScreenFocused, markAsRead]) return {children} } diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index 9456861d27..c322e0c62c 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -1,6 +1,7 @@ import {BskyAgent} from '@atproto-labs/api' -import {useQuery} from '@tanstack/react-query' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-converations' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useHeaders} from './temp-headers' @@ -23,3 +24,36 @@ export function useConvoQuery(convoId: string) { }, }) } + +export function useMarkAsReadMutation() { + const headers = useHeaders() + const {serviceUrl} = useDmServiceUrlStorage() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + convoId, + messageId, + }: { + convoId: string + messageId?: string + }) => { + const agent = new BskyAgent({service: serviceUrl}) + await agent.api.chat.bsky.convo.updateRead( + { + convoId, + messageId, + }, + { + encoding: 'application/json', + headers, + }, + ) + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: ListConvosQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts index 1e4ecb6d72..e66551ceb7 100644 --- a/src/state/queries/messages/list-converations.ts +++ b/src/state/queries/messages/list-converations.ts @@ -1,6 +1,12 @@ -import {BskyAgent} from '@atproto-labs/api' -import {useInfiniteQuery} from '@tanstack/react-query' +import {useCallback, useMemo} from 'react' +import { + BskyAgent, + ChatBskyConvoDefs, + ChatBskyConvoListConvos, +} from '@atproto-labs/api' +import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query' +import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useHeaders} from './temp-headers' @@ -27,3 +33,100 @@ export function useListConvos({refetchInterval}: {refetchInterval: number}) { refetchInterval, }) } + +export function useUnreadMessageCount() { + const {currentConvoId} = useCurrentConvoId() + const convos = useListConvos({ + refetchInterval: 30_000, + }) + + const count = + convos.data?.pages + .flatMap(page => page.convos) + .filter(convo => convo.id !== currentConvoId) + .reduce((acc, convo) => { + return acc + (!convo.muted && convo.unreadCount > 0 ? 1 : 0) + }, 0) ?? 0 + + return useMemo(() => { + return { + count, + numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, + } + }, [count]) +} + +type ConvoListQueryData = { + pageParams: Array + pages: Array +} + +export function useOnDeleteMessage() { + const queryClient = useQueryClient() + + return useCallback( + (chatId: string, messageId: string) => { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { + return optimisticUpdate(chatId, old, convo => + messageId === convo.lastMessage?.id + ? { + ...convo, + lastMessage: { + $type: 'chat.bsky.convo.defs#deletedMessageView', + id: messageId, + rev: '', + }, + } + : convo, + ) + }) + }, + [queryClient], + ) +} + +export function useOnNewMessage() { + const queryClient = useQueryClient() + + return useCallback( + (chatId: string, message: ChatBskyConvoDefs.MessageView) => { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { + return optimisticUpdate(chatId, old, convo => ({ + ...convo, + lastMessage: message, + unreadCount: convo.unreadCount + 1, + })) + }) + queryClient.invalidateQueries({queryKey: RQKEY}) + }, + [queryClient], + ) +} + +export function useOnCreateConvo() { + const queryClient = useQueryClient() + + return useCallback(() => { + queryClient.invalidateQueries({queryKey: RQKEY}) + }, [queryClient]) +} + +function optimisticUpdate( + chatId: string, + old: ConvoListQueryData, + updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, +) { + if (!old) { + return old + } + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.map(convo => + chatId === convo.id ? updateFn(convo) : convo, + ), + })), + } +} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 33f713322a..0db8b242a1 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -27,6 +27,7 @@ import {getTabState, TabState} from '#/lib/routes/helpers' import {useGate} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {emitSoftReset} from '#/state/events' +import {useUnreadMessageCount} from '#/state/queries/messages/list-converations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -68,6 +69,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { isAtMessages, } = useNavigationTabState() const numUnreadNotifications = useUnreadNotifications() + const numUnreadMessages = useUnreadMessageCount() const {footerMinimalShellTransform} = useMinimalShellMode() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {requestSwitchToAccount} = useLoggedOutViewControls() @@ -257,9 +259,15 @@ export function BottomBar({navigation}: BottomTabBarProps) { ) } onPress={onPressMessages} + notificationCount={numUnreadMessages.numUnread} + accessible={true} accessibilityRole="tab" accessibilityLabel={_(msg`Messages`)} - accessibilityHint="" + accessibilityHint={ + numUnreadMessages.count > 0 + ? `${numUnreadMessages.numUnread} unread` + : '' + } /> )} } iconFilled={