Fix issue with stale unread message counts (#10953)

(cherry picked from commit da1f637160)
This commit is contained in:
DS Boyce
2026-06-24 13:51:06 -07:00
committed by Eric Bailey
parent 105acc2474
commit 0a2213f64d
5 changed files with 132 additions and 3 deletions
+4 -1
View File
@@ -300,7 +300,10 @@ export function ChatList({
isError, isError,
error, error,
refetch, refetch,
} = useListConvosQuery({status: 'accepted'}) } = useListConvosQuery({
status: 'accepted',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
})
const {refetch: refetchInbox} = useListConvosQuery({ const {refetch: refetchInbox} = useListConvosQuery({
status: 'request', status: 'request',
@@ -2,6 +2,7 @@ import {
type ChatBskyActorDefs, type ChatBskyActorDefs,
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
type ChatBskyConvoGetConvo, type ChatBskyConvoGetConvo,
type ChatBskyConvoGetUnreadCounts,
} from '@atproto/api' } from '@atproto/api'
import { import {
type QueryClient, type QueryClient,
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations' import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
UNREAD_ACCEPTED_CAP,
UNREAD_REQUEST_CAP,
} from './get-unread-counts'
import { import {
type ConvoListQueryData, type ConvoListQueryData,
getConvoFromQueryData, getConvoFromQueryData,
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
}, },
onMutate({convoId}) { onMutate({convoId}) {
if (!convoId) throw new Error('No convoId provided') 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<ConvoListQueryData>({
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) 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<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
if (unreadStatus) {
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{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}) { onSuccess(_, {convoId}) {
if (!convoId) return 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( queryClient.setQueriesData(
{queryKey: [LIST_CONVOS_KEY]}, {queryKey: [LIST_CONVOS_KEY]},
(old?: ConvoListQueryData) => { (old?: ConvoListQueryData) => {
@@ -10,6 +10,14 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const [RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] 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() { export function useUnreadCountsQuery() {
const agent = useAgent() const agent = useAgent()
const {hasSession} = useSession() const {hasSession} = useSession()
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import { import {
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL, RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
UNREAD_ACCEPTED_CAP,
useUnreadCountsQuery, useUnreadCountsQuery,
} from './get-unread-counts' } from './get-unread-counts'
import { import {
@@ -858,7 +859,15 @@ export function useUnreadMessageCount(): {
const total = accepted + Math.min(request, 1) const total = accepted + Math.min(request, 1)
return { return {
count: total, 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 // only needed when numUnread is undefined
hasNew: false, hasNew: false,
} }
+34 -1
View File
@@ -1,8 +1,10 @@
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
import { import {
type ConvoRequestListQueryData, type ConvoRequestListQueryData,
markAllRead as markAllRequestsRead, markAllRead as markAllRequestsRead,
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
markAllRequestsRead, 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<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
old => {
if (!old) return old
return {
...old,
...(status === 'accepted'
? {unreadAcceptedConvos: 0}
: {unreadRequestConvos: 0}),
}
},
)
onMutate?.() onMutate?.()
return {prevConvoListQueries, prevRequestsQueries} return {
prevConvoListQueries,
prevRequestsQueries,
prevUnreadCountsQueries,
}
}, },
onSuccess: () => { 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({ void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY(status), queryKey: CONVO_LIST_PARTIAL_KEY(status),
}) })
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
queryClient.setQueryData(queryKey, prevData) 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]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
if (status === 'request') { if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]}) void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})