An ATproto social media client -- with an independent Appview.

[🐴] update convo list from message bus (#4189)

* update convo list from message bus

* don't increase unread count if you're the sender

* add refetch interval back

* Fix deleted message state copy

* only enable if `hasSession`

* Fix logged out handling

* increase refetch interval to 60s

* request 10s interval when message screen active

* use useAppState hook for convo resume/background

* Combine forces

* fix useFocusEffect logic

---------

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

authored by samuel.fm

Eric Bailey and committed by
GitHub
dc9d80d2 c0175af7

+376 -231
+15
src/lib/hooks/useAppState.ts
··· 1 + import {useEffect, useState} from 'react' 2 + import {AppState} from 'react-native' 3 + 4 + export function useAppState() { 5 + const [state, setState] = useState(AppState.currentState) 6 + 7 + useEffect(() => { 8 + const sub = AppState.addEventListener('change', nextAppState => { 9 + setState(nextAppState) 10 + }) 11 + return () => sub.remove() 12 + }, []) 13 + 14 + return state 15 + }
+3 -1
src/screens/Messages/List/ChatListItem.tsx
··· 105 105 lastMessageSentAt = convo.lastMessage.sentAt 106 106 } 107 107 if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { 108 - lastMessage = _(msg`Conversation deleted`) 108 + lastMessage = isDeletedAccount 109 + ? _(msg`Conversation deleted`) 110 + : _(msg`Message deleted`) 109 111 } 110 112 111 113 const [showActions, setShowActions] = useState(false)
+24 -4
src/screens/Messages/List/index.tsx
··· 1 - import React, {useCallback, useMemo, useState} from 'react' 1 + import React, {useCallback, useEffect, useMemo, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {ChatBskyConvoDefs} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import {useFocusEffect} from '@react-navigation/native' 6 7 import {NativeStackScreenProps} from '@react-navigation/native-stack' 7 8 9 + import {useAppState} from '#/lib/hooks/useAppState' 8 10 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 9 11 import {MessagesTabNavigatorParams} from '#/lib/routes/types' 10 12 import {cleanError} from '#/lib/strings/errors' 11 13 import {logger} from '#/logger' 12 14 import {isNative} from '#/platform/detection' 13 - import {useListConvos} from '#/state/queries/messages/list-converations' 15 + import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' 16 + import {useMessagesEventBus} from '#/state/messages/events' 17 + import {useListConvosQuery} from '#/state/queries/messages/list-converations' 14 18 import {List} from '#/view/com/util/List' 15 19 import {ViewHeader} from '#/view/com/util/ViewHeader' 16 20 import {CenteredView} from '#/view/com/util/Views' ··· 52 56 // this tab. We should immediately push to the conversation after pressing the notification. 53 57 // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if 54 58 // the conversation is the same as before 55 - React.useEffect(() => { 59 + useEffect(() => { 56 60 if (pushToConversation) { 57 61 navigation.navigate('MessagesConversation', { 58 62 conversation: pushToConversation, ··· 61 65 } 62 66 }, [navigation, pushToConversation]) 63 67 68 + // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) 69 + // but only when the screen is active 70 + const messagesBus = useMessagesEventBus() 71 + const state = useAppState() 72 + const isActive = state === 'active' 73 + useFocusEffect( 74 + useCallback(() => { 75 + if (isActive) { 76 + const unsub = messagesBus.requestPollInterval( 77 + MESSAGE_SCREEN_POLL_INTERVAL, 78 + ) 79 + return () => unsub() 80 + } 81 + }, [messagesBus, isActive]), 82 + ) 83 + 64 84 const renderButton = useCallback(() => { 65 85 return ( 66 86 <Link ··· 88 108 isError, 89 109 error, 90 110 refetch, 91 - } = useListConvos({refetchInterval: 15_000}) 111 + } = useListConvosQuery() 92 112 93 113 useRefreshOnFocus(refetch) 94 114
+1
src/state/messages/convo/const.ts
··· 1 1 export const ACTIVE_POLL_INTERVAL = 3e3 2 + export const MESSAGE_SCREEN_POLL_INTERVAL = 10e3 2 3 export const BACKGROUND_POLL_INTERVAL = 60e3 3 4 export const INACTIVE_TIMEOUT = 60e3 * 5 4 5
+12 -29
src/state/messages/convo/index.tsx
··· 1 1 import React, {useContext, useState, useSyncExternalStore} from 'react' 2 - import {AppState} from 'react-native' 3 - import {useFocusEffect, useIsFocused} from '@react-navigation/native' 2 + import {useFocusEffect} from '@react-navigation/native' 4 3 import {useQueryClient} from '@tanstack/react-query' 5 4 5 + import {useAppState} from '#/lib/hooks/useAppState' 6 6 import {Convo} from '#/state/messages/convo/agent' 7 7 import { 8 8 ConvoParams, ··· 58 58 convoId, 59 59 }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { 60 60 const queryClient = useQueryClient() 61 - const isScreenFocused = useIsFocused() 62 61 const {getAgent} = useAgent() 63 62 const events = useMessagesEventBus() 64 63 const [convo] = useState( ··· 72 71 const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) 73 72 const {mutate: markAsRead} = useMarkAsReadMutation() 74 73 74 + const appState = useAppState() 75 + const isActive = appState === 'active' 75 76 useFocusEffect( 76 77 React.useCallback(() => { 77 - convo.resume() 78 - markAsRead({convoId}) 78 + if (isActive) { 79 + convo.resume() 80 + markAsRead({convoId}) 79 81 80 - return () => { 81 - convo.background() 82 - markAsRead({convoId}) 82 + return () => { 83 + convo.background() 84 + markAsRead({convoId}) 85 + } 83 86 } 84 - }, [convo, convoId, markAsRead]), 87 + }, [isActive, convo, convoId, markAsRead]), 85 88 ) 86 89 87 90 React.useEffect(() => { ··· 100 103 } 101 104 }) 102 105 }, [convo, queryClient]) 103 - 104 - React.useEffect(() => { 105 - const handleAppStateChange = (nextAppState: string) => { 106 - if (isScreenFocused) { 107 - if (nextAppState === 'active') { 108 - convo.resume() 109 - } else { 110 - convo.background() 111 - } 112 - 113 - markAsRead({convoId}) 114 - } 115 - } 116 - 117 - const sub = AppState.addEventListener('change', handleAppStateChange) 118 - 119 - return () => { 120 - sub.remove() 121 - } 122 - }, [convoId, convo, isScreenFocused, markAsRead]) 123 106 124 107 return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> 125 108 }
+4 -1
src/state/messages/index.tsx
··· 2 2 3 3 import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id' 4 4 import {MessagesEventBusProvider} from '#/state/messages/events' 5 + import {ListConvosProvider} from '#/state/queries/messages/list-converations' 5 6 import {MessageDraftsProvider} from './message-drafts' 6 7 7 8 export function MessagesProvider({children}: {children: React.ReactNode}) { 8 9 return ( 9 10 <CurrentConvoIdProvider> 10 11 <MessageDraftsProvider> 11 - <MessagesEventBusProvider>{children}</MessagesEventBusProvider> 12 + <MessagesEventBusProvider> 13 + <ListConvosProvider>{children}</ListConvosProvider> 14 + </MessagesEventBusProvider> 12 15 </MessageDraftsProvider> 13 16 </CurrentConvoIdProvider> 14 17 )
-196
src/state/queries/messages/list-converations.ts
··· 1 - import {useCallback, useMemo} from 'react' 2 - import { 3 - ChatBskyConvoDefs, 4 - ChatBskyConvoListConvos, 5 - moderateProfile, 6 - } from '@atproto/api' 7 - import { 8 - InfiniteData, 9 - QueryClient, 10 - useInfiniteQuery, 11 - useQueryClient, 12 - } from '@tanstack/react-query' 13 - 14 - import {useCurrentConvoId} from '#/state/messages/current-convo-id' 15 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 - import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 17 - import {useAgent, useSession} from '#/state/session' 18 - 19 - export const RQKEY = ['convo-list'] 20 - type RQPageParam = string | undefined 21 - 22 - export function useListConvos({refetchInterval}: {refetchInterval: number}) { 23 - const {getAgent} = useAgent() 24 - 25 - return useInfiniteQuery({ 26 - queryKey: RQKEY, 27 - queryFn: async ({pageParam}) => { 28 - const {data} = await getAgent().api.chat.bsky.convo.listConvos( 29 - {cursor: pageParam}, 30 - {headers: DM_SERVICE_HEADERS}, 31 - ) 32 - 33 - return data 34 - }, 35 - initialPageParam: undefined as RQPageParam, 36 - getNextPageParam: lastPage => lastPage.cursor, 37 - refetchInterval, 38 - }) 39 - } 40 - 41 - export function useUnreadMessageCount() { 42 - const {currentConvoId} = useCurrentConvoId() 43 - const {currentAccount} = useSession() 44 - const convos = useListConvos({ 45 - refetchInterval: 30_000, 46 - }) 47 - const moderationOpts = useModerationOpts() 48 - 49 - const count = useMemo(() => { 50 - return ( 51 - convos.data?.pages 52 - .flatMap(page => page.convos) 53 - .filter(convo => convo.id !== currentConvoId) 54 - .reduce((acc, convo) => { 55 - const otherMember = convo.members.find( 56 - member => member.did !== currentAccount?.did, 57 - ) 58 - 59 - if (!otherMember || !moderationOpts) return acc 60 - 61 - const moderation = moderateProfile(otherMember, moderationOpts) 62 - const shouldIgnore = 63 - convo.muted || 64 - moderation.blocked || 65 - otherMember.did === 'missing.invalid' 66 - const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 67 - 68 - return acc + unreadCount 69 - }, 0) ?? 0 70 - ) 71 - }, [convos.data, currentAccount?.did, currentConvoId, moderationOpts]) 72 - 73 - return useMemo(() => { 74 - return { 75 - count, 76 - numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, 77 - } 78 - }, [count]) 79 - } 80 - 81 - type ConvoListQueryData = { 82 - pageParams: Array<string | undefined> 83 - pages: Array<ChatBskyConvoListConvos.OutputSchema> 84 - } 85 - 86 - export function useOnDeleteMessage() { 87 - const queryClient = useQueryClient() 88 - 89 - return useCallback( 90 - (chatId: string, messageId: string) => { 91 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 92 - return optimisticUpdate(chatId, old, convo => 93 - messageId === convo.lastMessage?.id 94 - ? { 95 - ...convo, 96 - lastMessage: { 97 - $type: 'chat.bsky.convo.defs#deletedMessageView', 98 - id: messageId, 99 - rev: '', 100 - }, 101 - } 102 - : convo, 103 - ) 104 - }) 105 - }, 106 - [queryClient], 107 - ) 108 - } 109 - 110 - export function useOnNewMessage() { 111 - const queryClient = useQueryClient() 112 - 113 - return useCallback( 114 - (chatId: string, message: ChatBskyConvoDefs.MessageView) => { 115 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 116 - return optimisticUpdate(chatId, old, convo => ({ 117 - ...convo, 118 - lastMessage: message, 119 - unreadCount: convo.unreadCount + 1, 120 - })) 121 - }) 122 - queryClient.invalidateQueries({queryKey: RQKEY}) 123 - }, 124 - [queryClient], 125 - ) 126 - } 127 - 128 - export function useOnCreateConvo() { 129 - const queryClient = useQueryClient() 130 - 131 - return useCallback(() => { 132 - queryClient.invalidateQueries({queryKey: RQKEY}) 133 - }, [queryClient]) 134 - } 135 - 136 - export function useOnMarkAsRead() { 137 - const queryClient = useQueryClient() 138 - 139 - return useCallback( 140 - (chatId: string) => { 141 - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 142 - return optimisticUpdate(chatId, old, convo => ({ 143 - ...convo, 144 - unreadCount: 0, 145 - })) 146 - }) 147 - }, 148 - [queryClient], 149 - ) 150 - } 151 - 152 - function optimisticUpdate( 153 - chatId: string, 154 - old: ConvoListQueryData, 155 - updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, 156 - ) { 157 - if (!old) { 158 - return old 159 - } 160 - 161 - return { 162 - ...old, 163 - pages: old.pages.map(page => ({ 164 - ...page, 165 - convos: page.convos.map(convo => 166 - chatId === convo.id ? updateFn(convo) : convo, 167 - ), 168 - })), 169 - } 170 - } 171 - 172 - export function* findAllProfilesInQueryData( 173 - queryClient: QueryClient, 174 - did: string, 175 - ) { 176 - const queryDatas = queryClient.getQueriesData< 177 - InfiniteData<ChatBskyConvoListConvos.OutputSchema> 178 - >({ 179 - queryKey: RQKEY, 180 - }) 181 - for (const [_queryKey, queryData] of queryDatas) { 182 - if (!queryData?.pages) { 183 - continue 184 - } 185 - 186 - for (const page of queryData.pages) { 187 - for (const convo of page.convos) { 188 - for (const member of convo.members) { 189 - if (member.did === did) { 190 - yield member 191 - } 192 - } 193 - } 194 - } 195 - } 196 - }
+317
src/state/queries/messages/list-converations.tsx
··· 1 + import React, { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useMemo, 7 + } from 'react' 8 + import { 9 + ChatBskyConvoDefs, 10 + ChatBskyConvoListConvos, 11 + moderateProfile, 12 + } from '@atproto/api' 13 + import { 14 + InfiniteData, 15 + QueryClient, 16 + useInfiniteQuery, 17 + useQueryClient, 18 + } from '@tanstack/react-query' 19 + 20 + import {useCurrentConvoId} from '#/state/messages/current-convo-id' 21 + import {useMessagesEventBus} from '#/state/messages/events' 22 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 23 + import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' 24 + import {useAgent, useSession} from '#/state/session' 25 + 26 + export const RQKEY = ['convo-list'] 27 + type RQPageParam = string | undefined 28 + 29 + export function useListConvosQuery() { 30 + const {getAgent} = useAgent() 31 + 32 + return useInfiniteQuery({ 33 + queryKey: RQKEY, 34 + queryFn: async ({pageParam}) => { 35 + const {data} = await getAgent().api.chat.bsky.convo.listConvos( 36 + {cursor: pageParam}, 37 + {headers: DM_SERVICE_HEADERS}, 38 + ) 39 + 40 + return data 41 + }, 42 + initialPageParam: undefined as RQPageParam, 43 + getNextPageParam: lastPage => lastPage.cursor, 44 + // refetch every 60 seconds since we can't get *all* info from the logs 45 + // i.e. reading chats on another device won't update the unread count 46 + refetchInterval: 60_000, 47 + }) 48 + } 49 + 50 + const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>( 51 + null, 52 + ) 53 + 54 + export function useListConvos() { 55 + const ctx = useContext(ListConvosContext) 56 + if (!ctx) { 57 + throw new Error('useListConvos must be used within a ListConvosProvider') 58 + } 59 + return ctx 60 + } 61 + 62 + export function ListConvosProvider({children}: {children: React.ReactNode}) { 63 + const {hasSession} = useSession() 64 + 65 + if (!hasSession) { 66 + return ( 67 + <ListConvosContext.Provider value={[]}> 68 + {children} 69 + </ListConvosContext.Provider> 70 + ) 71 + } 72 + 73 + return <ListConvosProviderInner>{children}</ListConvosProviderInner> 74 + } 75 + 76 + export function ListConvosProviderInner({ 77 + children, 78 + }: { 79 + children: React.ReactNode 80 + }) { 81 + const {refetch, data} = useListConvosQuery() 82 + const messagesBus = useMessagesEventBus() 83 + const queryClient = useQueryClient() 84 + const {currentConvoId} = useCurrentConvoId() 85 + const {currentAccount} = useSession() 86 + 87 + useEffect(() => { 88 + const unsub = messagesBus.on( 89 + events => { 90 + if (events.type !== 'logs') return 91 + 92 + events.logs.forEach(log => { 93 + if (ChatBskyConvoDefs.isLogBeginConvo(log)) { 94 + refetch() 95 + } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { 96 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => 97 + optimisticDelete(log.convoId, old), 98 + ) 99 + } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { 100 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => 101 + optimisticUpdate(log.convoId, old, convo => 102 + log.message.id === convo.lastMessage?.id 103 + ? { 104 + ...convo, 105 + rev: log.rev, 106 + lastMessage: log.message, 107 + } 108 + : convo, 109 + ), 110 + ) 111 + } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { 112 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 113 + if (!old) return old 114 + 115 + function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { 116 + if (!ChatBskyConvoDefs.isLogCreateMessage(log)) return convo 117 + 118 + let unreadCount = convo.unreadCount 119 + if (convo.id !== currentConvoId) { 120 + if ( 121 + ChatBskyConvoDefs.isMessageView(log.message) || 122 + ChatBskyConvoDefs.isDeletedMessageView(log.message) 123 + ) { 124 + if (log.message.sender.did !== currentAccount?.did) { 125 + unreadCount++ 126 + } 127 + } 128 + } else { 129 + unreadCount = 0 130 + } 131 + 132 + return { 133 + ...convo, 134 + rev: log.rev, 135 + lastMessage: log.message, 136 + unreadCount, 137 + } 138 + } 139 + 140 + function filterConvoFromPage( 141 + convo: ChatBskyConvoDefs.ConvoView[], 142 + ) { 143 + return convo.filter(c => c.id !== log.convoId) 144 + } 145 + 146 + const existingConvo = getConvoFromQueryData(log.convoId, old) 147 + 148 + if (existingConvo) { 149 + return { 150 + ...old, 151 + pages: old.pages.map((page, i) => { 152 + if (i === 0) { 153 + return { 154 + ...page, 155 + convos: [ 156 + updateConvo(existingConvo), 157 + ...filterConvoFromPage(page.convos), 158 + ], 159 + } 160 + } 161 + return { 162 + ...page, 163 + convos: filterConvoFromPage(page.convos), 164 + } 165 + }), 166 + } 167 + } else { 168 + refetch() 169 + } 170 + }) 171 + } 172 + }) 173 + }, 174 + { 175 + // get events for all chats 176 + convoId: undefined, 177 + }, 178 + ) 179 + 180 + return () => unsub() 181 + }, [messagesBus, currentConvoId, refetch, queryClient, currentAccount?.did]) 182 + 183 + const ctx = useMemo(() => { 184 + return data?.pages.flatMap(page => page.convos) ?? [] 185 + }, [data]) 186 + 187 + return ( 188 + <ListConvosContext.Provider value={ctx}> 189 + {children} 190 + </ListConvosContext.Provider> 191 + ) 192 + } 193 + 194 + export function useUnreadMessageCount() { 195 + const {currentConvoId} = useCurrentConvoId() 196 + const {currentAccount} = useSession() 197 + const convos = useListConvos() 198 + const moderationOpts = useModerationOpts() 199 + 200 + const count = useMemo(() => { 201 + return ( 202 + convos 203 + .filter(convo => convo.id !== currentConvoId) 204 + .reduce((acc, convo) => { 205 + const otherMember = convo.members.find( 206 + member => member.did !== currentAccount?.did, 207 + ) 208 + 209 + if (!otherMember || !moderationOpts) return acc 210 + 211 + const moderation = moderateProfile(otherMember, moderationOpts) 212 + const shouldIgnore = 213 + convo.muted || 214 + moderation.blocked || 215 + otherMember.did === 'missing.invalid' 216 + const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0 217 + 218 + return acc + unreadCount 219 + }, 0) ?? 0 220 + ) 221 + }, [convos, currentAccount?.did, currentConvoId, moderationOpts]) 222 + 223 + return useMemo(() => { 224 + return { 225 + count, 226 + numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, 227 + } 228 + }, [count]) 229 + } 230 + 231 + type ConvoListQueryData = { 232 + pageParams: Array<string | undefined> 233 + pages: Array<ChatBskyConvoListConvos.OutputSchema> 234 + } 235 + 236 + export function useOnMarkAsRead() { 237 + const queryClient = useQueryClient() 238 + 239 + return useCallback( 240 + (chatId: string) => { 241 + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { 242 + return optimisticUpdate(chatId, old, convo => ({ 243 + ...convo, 244 + unreadCount: 0, 245 + })) 246 + }) 247 + }, 248 + [queryClient], 249 + ) 250 + } 251 + 252 + function optimisticUpdate( 253 + chatId: string, 254 + old: ConvoListQueryData, 255 + updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView, 256 + ) { 257 + if (!old) return old 258 + 259 + return { 260 + ...old, 261 + pages: old.pages.map(page => ({ 262 + ...page, 263 + convos: page.convos.map(convo => 264 + chatId === convo.id ? updateFn(convo) : convo, 265 + ), 266 + })), 267 + } 268 + } 269 + 270 + function optimisticDelete(chatId: string, old: ConvoListQueryData) { 271 + if (!old) return old 272 + 273 + return { 274 + ...old, 275 + pages: old.pages.map(page => ({ 276 + ...page, 277 + convos: page.convos.filter(convo => chatId !== convo.id), 278 + })), 279 + } 280 + } 281 + 282 + function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) { 283 + for (const page of old.pages) { 284 + for (const convo of page.convos) { 285 + if (convo.id === chatId) { 286 + return convo 287 + } 288 + } 289 + } 290 + return null 291 + } 292 + 293 + export function* findAllProfilesInQueryData( 294 + queryClient: QueryClient, 295 + did: string, 296 + ) { 297 + const queryDatas = queryClient.getQueriesData< 298 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 299 + >({ 300 + queryKey: RQKEY, 301 + }) 302 + for (const [_queryKey, queryData] of queryDatas) { 303 + if (!queryData?.pages) { 304 + continue 305 + } 306 + 307 + for (const page of queryData.pages) { 308 + for (const convo of page.convos) { 309 + for (const member of convo.members) { 310 + if (member.did === did) { 311 + yield member 312 + } 313 + } 314 + } 315 + } 316 + } 317 + }