Bluesky app fork with some witchin' additions ๐Ÿ’ซ

[๐Ÿด] Unread messages badge (#3901)

* add badge

* move stringify logic to hook

* add mutation hooks

* optimistic mark convo as read

* don't count muted chats

* Integrate new context

* Integrate mark unread mutation

* Remove unused edit

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
4fe5a869 0c41b318

+162 -8
+8 -2
src/state/messages/index.tsx
··· 6 6 import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' 7 7 import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id' 8 8 import {MessagesEventBusProvider} from '#/state/messages/events' 9 + import {useMarkAsReadMutation} from '#/state/queries/messages/conversation' 9 10 import {useAgent} from '#/state/session' 10 11 import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 11 12 ··· 37 38 }), 38 39 ) 39 40 const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) 41 + const {mutate: markAsRead} = useMarkAsReadMutation() 40 42 41 43 useFocusEffect( 42 44 React.useCallback(() => { 43 45 convo.resume() 46 + markAsRead({convoId}) 44 47 45 48 return () => { 46 49 convo.background() 50 + markAsRead({convoId}) 47 51 } 48 - }, [convo]), 52 + }, [convo, convoId, markAsRead]), 49 53 ) 50 54 51 55 React.useEffect(() => { ··· 56 60 } else { 57 61 convo.background() 58 62 } 63 + 64 + markAsRead({convoId}) 59 65 } 60 66 } 61 67 ··· 64 70 return () => { 65 71 sub.remove() 66 72 } 67 - }, [convo, isScreenFocused]) 73 + }, [convoId, convo, isScreenFocused, markAsRead]) 68 74 69 75 return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> 70 76 }
+35 -1
src/state/queries/messages/conversation.ts
··· 1 1 import {BskyAgent} from '@atproto-labs/api' 2 - import {useQuery} from '@tanstack/react-query' 2 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 3 3 4 + import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-converations' 4 5 import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 6 import {useHeaders} from './temp-headers' 6 7 ··· 23 24 }, 24 25 }) 25 26 } 27 + 28 + export function useMarkAsReadMutation() { 29 + const headers = useHeaders() 30 + const {serviceUrl} = useDmServiceUrlStorage() 31 + const queryClient = useQueryClient() 32 + 33 + return useMutation({ 34 + mutationFn: async ({ 35 + convoId, 36 + messageId, 37 + }: { 38 + convoId: string 39 + messageId?: string 40 + }) => { 41 + const agent = new BskyAgent({service: serviceUrl}) 42 + await agent.api.chat.bsky.convo.updateRead( 43 + { 44 + convoId, 45 + messageId, 46 + }, 47 + { 48 + encoding: 'application/json', 49 + headers, 50 + }, 51 + ) 52 + }, 53 + onSuccess() { 54 + queryClient.invalidateQueries({ 55 + queryKey: ListConvosQueryKey, 56 + }) 57 + }, 58 + }) 59 + }
+105 -2
src/state/queries/messages/list-converations.ts
··· 1 - import {BskyAgent} from '@atproto-labs/api' 2 - import {useInfiniteQuery} from '@tanstack/react-query' 1 + import {useCallback, useMemo} from 'react' 2 + import { 3 + BskyAgent, 4 + ChatBskyConvoDefs, 5 + ChatBskyConvoListConvos, 6 + } from '@atproto-labs/api' 7 + import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query' 3 8 9 + import {useCurrentConvoId} from '#/state/messages/current-convo-id' 4 10 import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' 5 11 import {useHeaders} from './temp-headers' 6 12 ··· 27 33 refetchInterval, 28 34 }) 29 35 } 36 + 37 + export function useUnreadMessageCount() { 38 + const {currentConvoId} = useCurrentConvoId() 39 + const convos = useListConvos({ 40 + refetchInterval: 30_000, 41 + }) 42 + 43 + const count = 44 + convos.data?.pages 45 + .flatMap(page => page.convos) 46 + .filter(convo => convo.id !== currentConvoId) 47 + .reduce((acc, convo) => { 48 + return acc + (!convo.muted && convo.unreadCount > 0 ? 1 : 0) 49 + }, 0) ?? 0 50 + 51 + return useMemo(() => { 52 + return { 53 + count, 54 + numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, 55 + } 56 + }, [count]) 57 + } 58 + 59 + type ConvoListQueryData = { 60 + pageParams: Array<string | undefined> 61 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 62 + } 63 + 64 + export function useOnDeleteMessage() { 65 + const queryClient = useQueryClient() 66 + 67 + return useCallback( 68 + (chatId: string, messageId: string) => { 69 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 70 + return optimisticUpdate(chatId, old, convo => 71 + messageId === convo.lastMessage?.id 72 + ? { 73 + ...convo, 74 + lastMessage: { 75 + $type: 'chat.bsky.convo.defs#deletedMessageView', 76 + id: messageId, 77 + rev: '', 78 + }, 79 + } 80 + : convo, 81 + ) 82 + }) 83 + }, 84 + [queryClient], 85 + ) 86 + } 87 + 88 + export function useOnNewMessage() { 89 + const queryClient = useQueryClient() 90 + 91 + return useCallback( 92 + (chatId: string, message: ChatBskyConvoDefs.MessageView) => { 93 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 94 + return optimisticUpdate(chatId, old, convo => ({ 95 + ...convo, 96 + lastMessage: message, 97 + unreadCount: convo.unreadCount + 1, 98 + })) 99 + }) 100 + queryClient.invalidateQueries({queryKey: RQKEY}) 101 + }, 102 + [queryClient], 103 + ) 104 + } 105 + 106 + export function useOnCreateConvo() { 107 + const queryClient = useQueryClient() 108 + 109 + return useCallback(() => { 110 + queryClient.invalidateQueries({queryKey: RQKEY}) 111 + }, [queryClient]) 112 + } 113 + 114 + function optimisticUpdate( 115 + chatId: string, 116 + old: ConvoListQueryData, 117 + updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, 118 + ) { 119 + if (!old) { 120 + return old 121 + } 122 + 123 + return { 124 + ...old, 125 + pages: old.pages.map(page => ({ 126 + ...page, 127 + convos: page.convos.map(convo => 128 + chatId === convo.id ? updateFn(convo) : convo, 129 + ), 130 + })), 131 + } 132 + }
+9 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 27 27 import {useGate} from '#/lib/statsig/statsig' 28 28 import {s} from '#/lib/styles' 29 29 import {emitSoftReset} from '#/state/events' 30 + import {useUnreadMessageCount} from '#/state/queries/messages/list-converations' 30 31 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 31 32 import {useProfileQuery} from '#/state/queries/profile' 32 33 import {useSession} from '#/state/session' ··· 68 69 isAtMessages, 69 70 } = useNavigationTabState() 70 71 const numUnreadNotifications = useUnreadNotifications() 72 + const numUnreadMessages = useUnreadMessageCount() 71 73 const {footerMinimalShellTransform} = useMinimalShellMode() 72 74 const {data: profile} = useProfileQuery({did: currentAccount?.did}) 73 75 const {requestSwitchToAccount} = useLoggedOutViewControls() ··· 257 259 ) 258 260 } 259 261 onPress={onPressMessages} 262 + notificationCount={numUnreadMessages.numUnread} 263 + accessible={true} 260 264 accessibilityRole="tab" 261 265 accessibilityLabel={_(msg`Messages`)} 262 - accessibilityHint="" 266 + accessibilityHint={ 267 + numUnreadMessages.count > 0 268 + ? `${numUnreadMessages.numUnread} unread` 269 + : '' 270 + } 263 271 /> 264 272 )} 265 273 <Btn
+5 -2
src/view/shell/desktop/LeftNav.tsx
··· 16 16 import {isInvalidHandle} from '#/lib/strings/handles' 17 17 import {emitSoftReset} from '#/state/events' 18 18 import {useFetchHandle} from '#/state/queries/handle' 19 + import {useUnreadMessageCount} from '#/state/queries/messages/list-converations' 19 20 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 20 21 import {useProfileQuery} from '#/state/queries/profile' 21 22 import {useSession} from '#/state/session' ··· 274 275 const pal = usePalette('default') 275 276 const {_} = useLingui() 276 277 const {isDesktop, isTablet} = useWebMediaQueries() 277 - const numUnread = useUnreadNotifications() 278 + const numUnreadNotifications = useUnreadNotifications() 279 + const numUnreadMessages = useUnreadMessageCount() 278 280 const gate = useGate() 279 281 280 282 if (!hasSession && !isDesktop) { ··· 333 335 /> 334 336 <NavItem 335 337 href="/notifications" 336 - count={numUnread} 338 + count={numUnreadNotifications} 337 339 icon={ 338 340 <BellIcon 339 341 strokeWidth={2} ··· 353 355 {gate('dms') && ( 354 356 <NavItem 355 357 href="/messages" 358 + count={numUnreadMessages.numUnread} 356 359 icon={<Envelope style={pal.text} width={isDesktop ? 26 : 30} />} 357 360 iconFilled={ 358 361 <EnvelopeFilled style={pal.text} width={isDesktop ? 26 : 30} />