From dc9d80d2a84927119381eeee1b16e10099f08334 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 24 May 2024 19:59:28 +0100 Subject: [PATCH] =?UTF-8?q?[=F0=9F=90=B4]=20update=20convo=20list=20from?= =?UTF-8?q?=20message=20bus=20(#4189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/lib/hooks/useAppState.ts | 15 + src/screens/Messages/List/ChatListItem.tsx | 4 +- src/screens/Messages/List/index.tsx | 28 +- src/state/messages/convo/const.ts | 1 + src/state/messages/convo/index.tsx | 41 +-- src/state/messages/index.tsx | 5 +- .../queries/messages/list-converations.ts | 196 ----------- .../queries/messages/list-converations.tsx | 317 ++++++++++++++++++ 8 files changed, 376 insertions(+), 231 deletions(-) create mode 100644 src/lib/hooks/useAppState.ts delete mode 100644 src/state/queries/messages/list-converations.ts create mode 100644 src/state/queries/messages/list-converations.tsx diff --git a/src/lib/hooks/useAppState.ts b/src/lib/hooks/useAppState.ts new file mode 100644 index 00000000..7fb228d6 --- /dev/null +++ b/src/lib/hooks/useAppState.ts @@ -0,0 +1,15 @@ +import {useEffect, useState} from 'react' +import {AppState} from 'react-native' + +export function useAppState() { + const [state, setState] = useState(AppState.currentState) + + useEffect(() => { + const sub = AppState.addEventListener('change', nextAppState => { + setState(nextAppState) + }) + return () => sub.remove() + }, []) + + return state +} diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index 52fae7d2..a5c709a2 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -105,7 +105,9 @@ function ChatListItemReady({ lastMessageSentAt = convo.lastMessage.sentAt } if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessage = _(msg`Conversation deleted`) + lastMessage = isDeletedAccount + ? _(msg`Conversation deleted`) + : _(msg`Message deleted`) } const [showActions, setShowActions] = useState(false) diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 26b6df23..7c67c59d 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -1,16 +1,20 @@ -import React, {useCallback, useMemo, useState} from 'react' +import React, {useCallback, useEffect, useMemo, useState} from 'react' import {View} from 'react-native' import {ChatBskyConvoDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useAppState} from '#/lib/hooks/useAppState' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {MessagesTabNavigatorParams} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative} from '#/platform/detection' -import {useListConvos} from '#/state/queries/messages/list-converations' +import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' +import {useMessagesEventBus} from '#/state/messages/events' +import {useListConvosQuery} from '#/state/queries/messages/list-converations' import {List} from '#/view/com/util/List' import {ViewHeader} from '#/view/com/util/ViewHeader' import {CenteredView} from '#/view/com/util/Views' @@ -52,7 +56,7 @@ export function MessagesScreen({navigation, route}: Props) { // this tab. We should immediately push to the conversation after pressing the notification. // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if // the conversation is the same as before - React.useEffect(() => { + useEffect(() => { if (pushToConversation) { navigation.navigate('MessagesConversation', { conversation: pushToConversation, @@ -61,6 +65,22 @@ export function MessagesScreen({navigation, route}: Props) { } }, [navigation, pushToConversation]) + // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) + // but only when the screen is active + const messagesBus = useMessagesEventBus() + const state = useAppState() + const isActive = state === 'active' + useFocusEffect( + useCallback(() => { + if (isActive) { + const unsub = messagesBus.requestPollInterval( + MESSAGE_SCREEN_POLL_INTERVAL, + ) + return () => unsub() + } + }, [messagesBus, isActive]), + ) + const renderButton = useCallback(() => { return ( & {children: React.ReactNode}) { const queryClient = useQueryClient() - const isScreenFocused = useIsFocused() const {getAgent} = useAgent() const events = useMessagesEventBus() const [convo] = useState( @@ -72,16 +71,20 @@ export function ConvoProvider({ const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const {mutate: markAsRead} = useMarkAsReadMutation() + const appState = useAppState() + const isActive = appState === 'active' useFocusEffect( React.useCallback(() => { - convo.resume() - markAsRead({convoId}) - - return () => { - convo.background() + if (isActive) { + convo.resume() markAsRead({convoId}) + + return () => { + convo.background() + markAsRead({convoId}) + } } - }, [convo, convoId, markAsRead]), + }, [isActive, convo, convoId, markAsRead]), ) React.useEffect(() => { @@ -101,25 +104,5 @@ export function ConvoProvider({ }) }, [convo, queryClient]) - React.useEffect(() => { - const handleAppStateChange = (nextAppState: string) => { - if (isScreenFocused) { - if (nextAppState === 'active') { - convo.resume() - } else { - convo.background() - } - - markAsRead({convoId}) - } - } - - const sub = AppState.addEventListener('change', handleAppStateChange) - - return () => { - sub.remove() - } - }, [convoId, convo, isScreenFocused, markAsRead]) - return {children} } diff --git a/src/state/messages/index.tsx b/src/state/messages/index.tsx index 04ace8d6..a379c551 100644 --- a/src/state/messages/index.tsx +++ b/src/state/messages/index.tsx @@ -2,13 +2,16 @@ import React from 'react' import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id' import {MessagesEventBusProvider} from '#/state/messages/events' +import {ListConvosProvider} from '#/state/queries/messages/list-converations' import {MessageDraftsProvider} from './message-drafts' export function MessagesProvider({children}: {children: React.ReactNode}) { return ( - {children} + + {children} + ) diff --git a/src/state/queries/messages/list-converations.ts b/src/state/queries/messages/list-converations.ts deleted file mode 100644 index 493ee0d1..00000000 --- a/src/state/queries/messages/list-converations.ts +++ /dev/null @@ -1,196 +0,0 @@ -import {useCallback, useMemo} from 'react' -import { - ChatBskyConvoDefs, - ChatBskyConvoListConvos, - moderateProfile, -} from '@atproto/api' -import { - InfiniteData, - QueryClient, - useInfiniteQuery, - useQueryClient, -} from '@tanstack/react-query' - -import {useCurrentConvoId} from '#/state/messages/current-convo-id' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' -import {useAgent, useSession} from '#/state/session' - -export const RQKEY = ['convo-list'] -type RQPageParam = string | undefined - -export function useListConvos({refetchInterval}: {refetchInterval: number}) { - const {getAgent} = useAgent() - - return useInfiniteQuery({ - queryKey: RQKEY, - queryFn: async ({pageParam}) => { - const {data} = await getAgent().api.chat.bsky.convo.listConvos( - {cursor: pageParam}, - {headers: DM_SERVICE_HEADERS}, - ) - - return data - }, - initialPageParam: undefined as RQPageParam, - getNextPageParam: lastPage => lastPage.cursor, - refetchInterval, - }) -} - -export function useUnreadMessageCount() { - const {currentConvoId} = useCurrentConvoId() - const {currentAccount} = useSession() - const convos = useListConvos({ - refetchInterval: 30_000, - }) - const moderationOpts = useModerationOpts() - - const count = useMemo(() => { - return ( - convos.data?.pages - .flatMap(page => page.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 - ) - }, [convos.data, currentAccount?.did, currentConvoId, moderationOpts]) - - 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]) -} - -export function useOnMarkAsRead() { - const queryClient = useQueryClient() - - return useCallback( - (chatId: string) => { - queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { - return optimisticUpdate(chatId, old, convo => ({ - ...convo, - unreadCount: 0, - })) - }) - }, - [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, - ), - })), - } -} - -export function* findAllProfilesInQueryData( - queryClient: QueryClient, - did: string, -) { - const queryDatas = queryClient.getQueriesData< - InfiniteData - >({ - queryKey: RQKEY, - }) - for (const [_queryKey, queryData] of queryDatas) { - if (!queryData?.pages) { - continue - } - - for (const page of queryData.pages) { - for (const convo of page.convos) { - for (const member of convo.members) { - if (member.did === did) { - yield member - } - } - } - } - } -} diff --git a/src/state/queries/messages/list-converations.tsx b/src/state/queries/messages/list-converations.tsx new file mode 100644 index 00000000..13a4a3bf --- /dev/null +++ b/src/state/queries/messages/list-converations.tsx @@ -0,0 +1,317 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react' +import { + ChatBskyConvoDefs, + ChatBskyConvoListConvos, + moderateProfile, +} from '@atproto/api' +import { + InfiniteData, + QueryClient, + useInfiniteQuery, + useQueryClient, +} from '@tanstack/react-query' + +import {useCurrentConvoId} from '#/state/messages/current-convo-id' +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' + +export const RQKEY = ['convo-list'] +type RQPageParam = string | undefined + +export function useListConvosQuery() { + const {getAgent} = useAgent() + + return useInfiniteQuery({ + queryKey: RQKEY, + queryFn: async ({pageParam}) => { + const {data} = await getAgent().api.chat.bsky.convo.listConvos( + {cursor: pageParam}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + initialPageParam: undefined as RQPageParam, + getNextPageParam: lastPage => lastPage.cursor, + // refetch every 60 seconds since we can't get *all* info from the logs + // i.e. reading chats on another device won't update the unread count + refetchInterval: 60_000, + }) +} + +const ListConvosContext = createContext( + null, +) + +export function useListConvos() { + const ctx = useContext(ListConvosContext) + if (!ctx) { + throw new Error('useListConvos must be used within a ListConvosProvider') + } + return ctx +} + +export function ListConvosProvider({children}: {children: React.ReactNode}) { + const {hasSession} = useSession() + + if (!hasSession) { + return ( + + {children} + + ) + } + + return {children} +} + +export function ListConvosProviderInner({ + children, +}: { + children: React.ReactNode +}) { + const {refetch, data} = useListConvosQuery() + const messagesBus = useMessagesEventBus() + const queryClient = useQueryClient() + const {currentConvoId} = useCurrentConvoId() + const {currentAccount} = useSession() + + useEffect(() => { + const unsub = messagesBus.on( + events => { + if (events.type !== 'logs') return + + events.logs.forEach(log => { + if (ChatBskyConvoDefs.isLogBeginConvo(log)) { + refetch() + } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => + optimisticDelete(log.convoId, old), + ) + } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => + optimisticUpdate(log.convoId, old, convo => + log.message.id === convo.lastMessage?.id + ? { + ...convo, + rev: log.rev, + lastMessage: log.message, + } + : convo, + ), + ) + } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { + if (!old) return old + + function updateConvo(convo: ChatBskyConvoDefs.ConvoView) { + if (!ChatBskyConvoDefs.isLogCreateMessage(log)) return convo + + let unreadCount = convo.unreadCount + if (convo.id !== currentConvoId) { + if ( + ChatBskyConvoDefs.isMessageView(log.message) || + ChatBskyConvoDefs.isDeletedMessageView(log.message) + ) { + if (log.message.sender.did !== currentAccount?.did) { + unreadCount++ + } + } + } else { + unreadCount = 0 + } + + return { + ...convo, + rev: log.rev, + lastMessage: log.message, + unreadCount, + } + } + + function filterConvoFromPage( + convo: ChatBskyConvoDefs.ConvoView[], + ) { + return convo.filter(c => c.id !== log.convoId) + } + + const existingConvo = getConvoFromQueryData(log.convoId, old) + + if (existingConvo) { + return { + ...old, + pages: old.pages.map((page, i) => { + if (i === 0) { + return { + ...page, + convos: [ + updateConvo(existingConvo), + ...filterConvoFromPage(page.convos), + ], + } + } + return { + ...page, + convos: filterConvoFromPage(page.convos), + } + }), + } + } else { + refetch() + } + }) + } + }) + }, + { + // get events for all chats + convoId: undefined, + }, + ) + + return () => unsub() + }, [messagesBus, currentConvoId, refetch, queryClient, currentAccount?.did]) + + const ctx = useMemo(() => { + return data?.pages.flatMap(page => page.convos) ?? [] + }, [data]) + + return ( + + {children} + + ) +} + +export function useUnreadMessageCount() { + const {currentConvoId} = useCurrentConvoId() + const {currentAccount} = useSession() + const convos = 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 + ) + }, [convos, currentAccount?.did, currentConvoId, moderationOpts]) + + return useMemo(() => { + return { + count, + numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined, + } + }, [count]) +} + +type ConvoListQueryData = { + pageParams: Array + pages: Array +} + +export function useOnMarkAsRead() { + const queryClient = useQueryClient() + + return useCallback( + (chatId: string) => { + queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => { + return optimisticUpdate(chatId, old, convo => ({ + ...convo, + unreadCount: 0, + })) + }) + }, + [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, + ), + })), + } +} + +function optimisticDelete(chatId: string, old: ConvoListQueryData) { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + convos: page.convos.filter(convo => chatId !== convo.id), + })), + } +} + +function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) { + for (const page of old.pages) { + for (const convo of page.convos) { + if (convo.id === chatId) { + return convo + } + } + } + return null +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +) { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: RQKEY, + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + + for (const page of queryData.pages) { + for (const convo of page.convos) { + for (const member of convo.members) { + if (member.did === did) { + yield member + } + } + } + } + } +}