From 0a2213f64d86f0a2eaae128c3eb468008ca39d1c Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:51:06 -0700 Subject: [PATCH] Fix issue with stale unread message counts (#10953) (cherry picked from commit da1f6371605c9ddf31b69cd308658126b18a0d07) --- src/screens/Messages/ChatList.tsx | 5 +- src/state/queries/messages/conversation.ts | 76 +++++++++++++++++++ .../queries/messages/get-unread-counts.ts | 8 ++ .../queries/messages/list-conversations.tsx | 11 ++- src/state/queries/messages/update-all-read.ts | 35 ++++++++- 5 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index 3bc027dc4c..53c342a799 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -300,7 +300,10 @@ export function ChatList({ isError, error, refetch, - } = useListConvosQuery({status: 'accepted'}) + } = useListConvosQuery({ + status: 'accepted', + kind: aa.flags.groupChatDisabled ? 'direct' : 'all', + }) const {refetch: refetchInbox} = useListConvosQuery({ status: 'request', diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts index 2ce6c06e51..1b7699c0ef 100644 --- a/src/state/queries/messages/conversation.ts +++ b/src/state/queries/messages/conversation.ts @@ -2,6 +2,7 @@ import { type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoGetConvo, + type ChatBskyConvoGetUnreadCounts, } from '@atproto/api' import { type QueryClient, @@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants' import {STALE} from '#/state/queries' import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations' import {useAgent} from '#/state/session' +import { + RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY, + UNREAD_ACCEPTED_CAP, + UNREAD_REQUEST_CAP, +} from './get-unread-counts' import { type ConvoListQueryData, getConvoFromQueryData, @@ -74,11 +80,81 @@ export function useMarkAsReadMutation() { }, onMutate({convoId}) { if (!convoId) throw new Error('No convoId provided') + + // snapshot the list caches before the optimistic update so onError can + // restore the convo rows alongside the badge count + const prevListQueries = queryClient.getQueriesData({ + queryKey: [LIST_CONVOS_KEY], + }) + + // find the convo so we know which badge counter (if any) to decrement. + // keep scanning past a stale unreadCount === 0 cache so another cache + // holding the true unread state still drives the decrement + let unreadStatus: ChatBskyConvoDefs.ConvoView['status'] | undefined + for (const [, data] of prevListQueries) { + if (!data) continue + const convo = getConvoFromQueryData(convoId, data) + if (convo?.unreadCount) { + unreadStatus = convo.status + break + } + } + optimisticUpdate(convoId) + + // the badge count query is a separate server query that the list caches + // don't feed, so decrement it here to keep the badge in sync + const prevUnreadCountsQueries = + queryClient.getQueriesData({ + queryKey: UNREAD_COUNTS_PARTIAL_KEY, + }) + if (unreadStatus) { + queryClient.setQueriesData( + {queryKey: UNREAD_COUNTS_PARTIAL_KEY}, + old => { + if (!old) return old + return { + ...old, + ...(unreadStatus === 'request' + ? { + unreadRequestConvos: + old.unreadRequestConvos >= UNREAD_REQUEST_CAP + ? old.unreadRequestConvos + : Math.max(0, old.unreadRequestConvos - 1), + } + : { + unreadAcceptedConvos: + old.unreadAcceptedConvos >= UNREAD_ACCEPTED_CAP + ? old.unreadAcceptedConvos + : Math.max(0, old.unreadAcceptedConvos - 1), + }), + } + }, + ) + } + return {prevListQueries, prevUnreadCountsQueries} + }, + onError(_, __, context) { + if (context?.prevListQueries) { + for (const [queryKey, prevData] of context.prevListQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } + if (context?.prevUnreadCountsQueries) { + for (const [queryKey, prevData] of context.prevUnreadCountsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } }, onSuccess(_, {convoId}) { if (!convoId) return + // the optimistic badge arithmetic can drift from the server (e.g. a convo + // whose status differs between caches, or a sentinel-capped count). invalidate + // so the 15s-stale count query self-corrects on next access rather than + // waiting for a log event + void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY}) + queryClient.setQueriesData( {queryKey: [LIST_CONVOS_KEY]}, (old?: ConvoListQueryData) => { diff --git a/src/state/queries/messages/get-unread-counts.ts b/src/state/queries/messages/get-unread-counts.ts index 1073d569e5..57b565fa17 100644 --- a/src/state/queries/messages/get-unread-counts.ts +++ b/src/state/queries/messages/get-unread-counts.ts @@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) => [RQKEY_ROOT, includeGroupChats] as const export const RQKEY_PARTIAL = [RQKEY_ROOT] as const +// the server sentinel-caps the badge counts: unreadAcceptedConvos maxes at 31 +// (meaning "more than 30") and unreadRequestConvos at 11 (meaning "more than +// 10"). at the cap the value is no longer an exact count, so consumers must not +// treat it as one - both the optimistic decrement and the badge display ceiling +// key off these. +export const UNREAD_ACCEPTED_CAP = 31 +export const UNREAD_REQUEST_CAP = 11 + export function useUnreadCountsQuery() { const agent = useAgent() const {hasSession} = useSession() diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index 6a326ca14e..3a3183bad1 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky' import {RQKEY as CONVO_KEY} from './conversation' import { RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL, + UNREAD_ACCEPTED_CAP, useUnreadCountsQuery, } from './get-unread-counts' import { @@ -858,7 +859,15 @@ export function useUnreadMessageCount(): { const total = accepted + Math.min(request, 1) return { count: total, - numUnread: total > 10 ? '10+' : String(total), + // accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than + // cap - 1"). show the "+" overflow label only when accepted is actually + // capped - the +1 request nudge must not trip it at exactly cap - 1 + // accepted convos. otherwise clamp the number to cap - 1 so the nudge + // never surfaces the sentinel value (31) itself + numUnread: + accepted >= UNREAD_ACCEPTED_CAP + ? `${UNREAD_ACCEPTED_CAP - 1}+` + : String(Math.min(total, UNREAD_ACCEPTED_CAP - 1)), // only needed when numUnread is undefined hasNew: false, } diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts index a6a7f45741..6fbffc58cd 100644 --- a/src/state/queries/messages/update-all-read.ts +++ b/src/state/queries/messages/update-all-read.ts @@ -1,8 +1,10 @@ +import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api' import {useMutation, useQueryClient} from '@tanstack/react-query' import {DM_SERVICE_HEADERS} from '#/lib/constants' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts' import { type ConvoRequestListQueryData, markAllRead as markAllRequestsRead, @@ -94,10 +96,36 @@ export function useUpdateAllRead( markAllRequestsRead, ) } + // zero out the badge count query that actually drives the unread badge, + // since it's a separate server query that the list caches don't feed + const prevUnreadCountsQueries = + queryClient.getQueriesData({ + queryKey: UNREAD_COUNTS_PARTIAL_KEY, + }) + queryClient.setQueriesData( + {queryKey: UNREAD_COUNTS_PARTIAL_KEY}, + old => { + if (!old) return old + return { + ...old, + ...(status === 'accepted' + ? {unreadAcceptedConvos: 0} + : {unreadRequestConvos: 0}), + } + }, + ) onMutate?.() - return {prevConvoListQueries, prevRequestsQueries} + return { + prevConvoListQueries, + prevRequestsQueries, + prevUnreadCountsQueries, + } }, onSuccess: () => { + // the optimistic badge zeroing can drift from the server, so invalidate + // the count query to let it self-correct on next access rather than + // waiting for a log event + void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY}) void queryClient.invalidateQueries({ queryKey: CONVO_LIST_PARTIAL_KEY(status), }) @@ -121,6 +149,11 @@ export function useUpdateAllRead( queryClient.setQueryData(queryKey, prevData) } } + if (context?.prevUnreadCountsQueries) { + for (const [queryKey, prevData] of context.prevUnreadCountsQueries) { + queryClient.setQueryData(queryKey, prevData) + } + } void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]}) if (status === 'request') { void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})