From e057d3bd544b19d71ddfcddc6a4998b563158798 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:45:07 -0700 Subject: [PATCH] Get unread counts vs counting convos (#10878) --- src/screens/Messages/ChatList.tsx | 33 +-- .../Messages/components/InboxRequests.tsx | 19 +- .../queries/messages/get-unread-counts.ts | 31 +++ .../queries/messages/list-conversations.tsx | 199 +++++------------- 4 files changed, 103 insertions(+), 179 deletions(-) create mode 100644 src/state/queries/messages/get-unread-counts.ts diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 1a5aa6c7b2..f7c44ec2d2 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {View} from 'react-native' import {useAnimatedRef} from 'react-native-reanimated' -import {type ChatBskyActorGetStatus, ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {type NativeStackScreenProps} from '@react-navigation/native-stack' @@ -17,6 +17,7 @@ import {listenSoftReset} from '#/state/events' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {useMessagesEventBus} from '#/state/messages/events' import {useChatActorStatusQuery} from '#/state/queries/messages/get-status' +import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {EmptyState} from '#/view/com/util/EmptyState' import {List, type ListRef} from '#/view/com/util/List' @@ -507,7 +508,6 @@ export function Header({ }) { const {t: l} = useLingui() const {gtMobile} = useBreakpoints() - const aa = useAgeAssurance() const requireEmailVerification = useRequireEmailVerification() const {isWithinSplitView} = useIsWithinSplitView() @@ -516,24 +516,8 @@ export function Header({ // on repeated clicks, so navigate instead to dedupe by route + params. const action = isWithinSplitView ? 'navigate' : 'push' - const {data: unreadInboxData, hasNextPage: hasMoreRequests} = - useListConvosQuery({ - status: 'request', - readState: 'unread', - kind: aa.flags.groupChatDisabled ? 'direct' : 'all', - }) - - const inboxAllConvos = - unreadInboxData?.pages - .flatMap(page => page.convos) - .filter( - convo => - !convo.muted && - convo.members.every(member => member.handle !== 'missing.invalid') && - (ChatBskyConvoDefs.isGroupConvo(convo.kind) - ? !aa.flags.groupChatDisabled - : true), - ) ?? [] + const {data: unreadCounts} = useUnreadCountsQuery() + const requestCount = unreadCounts?.unreadRequestConvos ?? 0 const openChatControl = useCallback(() => { newChatControl.open() @@ -558,8 +542,7 @@ export function Header({ @@ -593,11 +576,7 @@ export function Header({ Chats - + 0 + const overflow = count >= REQUEST_COUNT_CAP const label = !unread ? l({ message: `Requests`, comment: 'Incoming message requests', }) - : count >= UNREAD_LIMIT && more + : overflow ? l({ - message: `${count}+ requests`, - comment: 'Displayed when the number of requests is greater than 20', + message: `10+ requests`, + comment: 'Displayed when the number of requests is greater than 10', }) : plural(count, { one: '# request', @@ -52,11 +53,11 @@ export function InboxRequests({ {unread && ( - {count >= UNREAD_LIMIT && more + {overflow ? l({ - message: `${count}+`, + message: `10+`, comment: - 'Displayed when the number of requests is greater than 20', + 'Displayed when the number of requests is greater than 10', }) : count} diff --git a/src/state/queries/messages/get-unread-counts.ts b/src/state/queries/messages/get-unread-counts.ts new file mode 100644 index 0000000000..1073d569e5 --- /dev/null +++ b/src/state/queries/messages/get-unread-counts.ts @@ -0,0 +1,31 @@ +import {useQuery} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {useAgent, useSession} from '#/state/session' +import {useAgeAssurance} from '#/ageAssurance' +import {STALE} from '..' + +const RQKEY_ROOT = 'convo-unread-counts' +export const RQKEY = (includeGroupChats: boolean) => + [RQKEY_ROOT, includeGroupChats] as const +export const RQKEY_PARTIAL = [RQKEY_ROOT] as const + +export function useUnreadCountsQuery() { + const agent = useAgent() + const {hasSession} = useSession() + const aa = useAgeAssurance() + const includeGroupChats = !aa.flags.groupChatDisabled + + return useQuery({ + queryKey: RQKEY(includeGroupChats), + queryFn: async () => { + const {data} = await agent.chat.bsky.convo.getUnreadCounts( + {includeGroupChats}, + {headers: DM_SERVICE_HEADERS}, + ) + return data + }, + staleTime: STALE.SECONDS.FIFTEEN, + enabled: hasSession, + }) +} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 3ba857e155..6a326ca14e 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -1,10 +1,8 @@ -import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import { type ChatBskyActorDefs, ChatBskyConvoDefs, type ChatBskyConvoListConvos, - moderateProfile, - type ModerationOpts, } from '@atproto/api' import { type InfiniteData, @@ -19,14 +17,14 @@ import throttle from 'lodash.throttle' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useMessagesEventBus} from '#/state/messages/events' -import {useModerationOpts} from '#/state/preferences/moderation-opts' import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links' import {useAgent, useSession} from '#/state/session' -import {parseConvoView} from '#/components/dms/util' -import {useAgeAssurance} from '#/ageAssurance' -import {type AgeAssuranceFlags} from '#/ageAssurance/types' import * as bsky from '#/types/bsky' import {RQKEY as CONVO_KEY} from './conversation' +import { + RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL, + useUnreadCountsQuery, +} from './get-unread-counts' import { type ConvoRequestListQueryData, optimisticDelete as optimisticDeleteRequest, @@ -37,7 +35,6 @@ import { import {listConvoMembersQueryKey} from './list-convo-members' const DEFAULT_LIMIT = 10 -export const UNREAD_LIMIT = 20 export const RQKEY_ROOT = 'convo-list' export const RQKEY = ( @@ -145,30 +142,11 @@ export function useListConvosQuery({ }) } -const ListConvosContext = createContext<{ - accepted: ChatBskyConvoDefs.ConvoView[] - request: ChatBskyConvoDefs.ConvoView[] -} | null>(null) -ListConvosContext.displayName = 'ListConvosContext' - -export function useListConvos() { - const ctx = useContext(ListConvosContext) - if (!ctx) { - throw new Error('useListConvos must be used within a ListConvosProvider') - } - return ctx -} - -const empty = {accepted: [], request: []} export function ListConvosProvider({children}: {children: React.ReactNode}) { const {hasSession} = useSession() if (!hasSession) { - return ( - - {children} - - ) + return <>{children} } return {children} @@ -179,13 +157,6 @@ export function ListConvosProviderInner({ }: { children: React.ReactNode }) { - const aa = useAgeAssurance() - const {refetch, data} = useListConvosQuery({ - readState: 'unread', - limit: UNREAD_LIMIT, - lockStatus: 'unlocked', - kind: aa.flags.groupChatDisabled ? 'direct' : 'all', - }) const messagesBus = useMessagesEventBus() const queryClient = useQueryClient() const {currentConvoId} = useCurrentConvoId() @@ -193,7 +164,6 @@ export function ListConvosProviderInner({ const debouncedRefetch = useMemo(() => { const refetchAndInvalidate = () => { - void refetch() void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) } @@ -201,13 +171,31 @@ export function ListConvosProviderInner({ leading: true, trailing: true, }) - }, [refetch, queryClient]) + }, [queryClient]) + + // The unread badge count is derived from chat.bsky.convo.getUnreadCounts. + // Any chat log can change it, so refresh it (throttled) on every batch. + const debouncedInvalidateUnreadCounts = useMemo(() => { + return throttle( + () => { + void queryClient.invalidateQueries({ + queryKey: UNREAD_COUNTS_RQKEY_PARTIAL, + }) + }, + 500, + {leading: true, trailing: true}, + ) + }, [queryClient]) useEffect(() => { const unsub = messagesBus.on( events => { if (events.type !== 'logs') return + // Any log batch may change unread state (new message, read, accept, + // join request, etc.), so refresh the badge count for all of them. + debouncedInvalidateUnreadCounts() + function mutateMembers( convoId: string, fn: ( @@ -851,117 +839,42 @@ export function ListConvosProviderInner({ queryClient, currentAccount?.did, debouncedRefetch, + debouncedInvalidateUnreadCounts, ]) - const ctx = useMemo(() => { - const convos = data?.pages.flatMap(page => page.convos) ?? [] + return <>{children} +} + +export function useUnreadMessageCount(): { + count: number + numUnread?: string + hasNew: boolean +} { + const {data} = useUnreadCountsQuery() + const accepted = data?.unreadAcceptedConvos ?? 0 + const request = data?.unreadRequestConvos ?? 0 + + if (accepted > 0) { + const total = accepted + Math.min(request, 1) return { - accepted: convos.filter(conv => conv.status === 'accepted'), - request: convos.filter(conv => conv.status === 'request'), + count: total, + numUnread: total > 10 ? '10+' : String(total), + // only needed when numUnread is undefined + hasNew: false, } - }, [data]) - - return ( - - {children} - - ) -} - -export function useUnreadMessageCount() { - const {currentConvoId} = useCurrentConvoId() - const {currentAccount} = useSession() - const {accepted, request} = useListConvos() - const moderationOpts = useModerationOpts() - const aa = useAgeAssurance() - - return useMemo<{ - count: number - numUnread?: string - hasNew: boolean - }>(() => { - const acceptedCount = calculateCount( - accepted, - currentAccount?.did, - currentConvoId, - moderationOpts, - aa.flags, - ) - const requestCount = calculateCount( - request, - currentAccount?.did, - currentConvoId, - moderationOpts, - aa.flags, - ) - if (acceptedCount > 0) { - const total = acceptedCount + Math.min(requestCount, 1) - return { - count: total, - numUnread: total > 10 ? '10+' : String(total), - // only needed when numUnread is undefined - hasNew: false, - } - } else if (requestCount > 0) { - return { - count: 1, - numUnread: undefined, - hasNew: true, - } - } else { - return { - count: 0, - numUnread: undefined, - hasNew: false, - } + } else if (request > 0) { + return { + count: 1, + numUnread: undefined, + hasNew: true, } - }, [ - accepted, - request, - currentAccount?.did, - currentConvoId, - moderationOpts, - aa.flags, - ]) -} - -function calculateCount( - convos: ChatBskyConvoDefs.ConvoView[], - currentAccountDid: string | undefined, - currentConvoId: string | undefined, - moderationOpts: ModerationOpts | undefined, - flags: AgeAssuranceFlags, -) { - return ( - convos - .filter(convo => convo.id !== currentConvoId) - .reduce((acc, convoView) => { - const convo = parseConvoView(convoView, currentAccountDid) - - if (!convo || !moderationOpts) return acc - - if (convo.kind === 'group' && flags.groupChatDisabled) return acc - - const shouldIgnore = - convo.view.muted || - !convo.primaryMember || - moderateProfile(convo.primaryMember, moderationOpts).blocked || - convo.primaryMember.handle === 'missing.invalid' || - (convo.kind === 'group' && convo.details.lockStatus !== 'unlocked') - const unreadJoinRequestCount = - convo.kind === 'group' - ? (convo.details.unreadJoinRequestCount ?? 0) - : 0 - - const unreadCount = - !shouldIgnore && - (convo.view.unreadCount > 0 || unreadJoinRequestCount > 0) - ? 1 - : 0 - - return acc + unreadCount - }, 0) ?? 0 - ) + } else { + return { + count: 0, + numUnread: undefined, + hasNew: false, + } + } } export type ConvoListQueryData = {