remove leave-convo suppression, use real optimistic update

useLeftConvos() filtered convos out of every list based on leave-mutation
state, working around a slow backend leaveConvo query. now that the
backend is fast, replace it with a working optimistic update: the old
onMutate used setQueryData with just the root key, which never matched
any real query, so it was a no-op. switch to setQueriesData (prefix
match) across both the convo-list and requests caches, with snapshot
rollback on error, mirroring useAcceptConversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-10 10:02:20 +03:00
parent d6a14ade54
commit fce5402e6f
4 changed files with 48 additions and 77 deletions
+2 -10
View File
@@ -16,7 +16,6 @@ import {listenSoftReset} from '#/state/events'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status' import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {EmptyState} from '#/view/com/util/EmptyState' import {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
@@ -237,14 +236,9 @@ export function ChatList({
useRefreshOnFocus(refetch) useRefreshOnFocus(refetch)
useRefreshOnFocus(refetchInbox) useRefreshOnFocus(refetchInbox)
const leftConvos = useLeftConvos()
const conversations = useMemo(() => { const conversations = useMemo(() => {
if (data?.pages) { if (data?.pages) {
const conversations = data.pages const conversations = data.pages.flatMap(page => page.convos)
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
return conversations.map( return conversations.map(
convo => convo =>
@@ -256,7 +250,7 @@ export function ChatList({
) satisfies ListItem[] ) satisfies ListItem[]
} }
return [] return []
}, [data, leftConvos, selectedChat]) }, [data, selectedChat])
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
@@ -453,7 +447,6 @@ export function Header({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const leftConvos = useLeftConvos()
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinSplitView} = useIsWithinSplitView()
// In split view, the left column (and this header) stays mounted while the // In split view, the left column (and this header) stays mounted while the
@@ -473,7 +466,6 @@ export function Header({
.flatMap(page => page.convos) .flatMap(page => page.convos)
.filter( .filter(
convo => convo =>
!leftConvos.includes(convo.id) &&
!convo.muted && !convo.muted &&
convo.members.every(member => member.handle !== 'missing.invalid') && convo.members.every(member => member.handle !== 'missing.invalid') &&
(ChatBskyConvoDefs.isGroupConvo(convo.kind) (ChatBskyConvoDefs.isGroupConvo(convo.kind)
+1 -6
View File
@@ -23,7 +23,6 @@ import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests' import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read' import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState' import {EmptyState} from '#/view/com/util/EmptyState'
@@ -70,16 +69,12 @@ export function MessagesInboxScreenInner({}: Props) {
const listConvosQuery = useListConvoRequests() const listConvosQuery = useListConvoRequests()
const {data} = listConvosQuery const {data} = listConvosQuery
const leftConvos = useLeftConvos()
const conversations = useMemo<RequestItem[]>(() => { const conversations = useMemo<RequestItem[]>(() => {
if (!data?.pages) return [] if (!data?.pages) return []
const items: RequestItem[] = [] const items: RequestItem[] = []
for (const page of data.pages) { for (const page of data.pages) {
for (const item of page.requests) { for (const item of page.requests) {
if (ChatBskyConvoDefs.isConvoView(item)) { if (ChatBskyConvoDefs.isConvoView(item)) {
// filter out convos that are actively being left
if (leftConvos.includes(item.id)) continue
items.push({type: 'incoming', view: item}) items.push({type: 'incoming', view: item})
} else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) { } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) {
items.push({type: 'outgoing', view: item}) items.push({type: 'outgoing', view: item})
@@ -87,7 +82,7 @@ export function MessagesInboxScreenInner({}: Props) {
} }
} }
return items return items
}, [data, leftConvos]) }, [data])
const hasUnreadConvos = useMemo(() => { const hasUnreadConvos = useMemo(() => {
return conversations.some( return conversations.some(
@@ -1,18 +1,18 @@
import {useMemo} from 'react'
import { import {
type ChatBskyConvoLeaveConvo, type ChatBskyConvoLeaveConvo,
type ChatBskyConvoListConvos, type ChatBskyConvoListConvos,
} from '@atproto/api' } from '@atproto/api'
import { import {useMutation, useQueryClient} from '@tanstack/react-query'
useMutation,
useMutationState,
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 {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links' import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {
type ConvoRequestListQueryData,
optimisticDelete as optimisticDeleteRequest,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
const RQKEY_ROOT = 'leave-convo' const RQKEY_ROOT = 'leave-convo'
@@ -20,6 +20,11 @@ export function RQKEY(convoId: string | undefined) {
return [RQKEY_ROOT, convoId] return [RQKEY_ROOT, convoId]
} }
type ConvoListQueryData = {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}
export function useLeaveConvo( export function useLeaveConvo(
convoId: string | undefined, convoId: string | undefined,
{ {
@@ -48,31 +53,37 @@ export function useLeaveConvo(
return data return data
}, },
onMutate: () => { onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = [] const prevConvoListQueries =
queryClient.setQueryData( queryClient.getQueriesData<ConvoListQueryData>({
[CONVO_LIST_KEY], queryKey: [CONVO_LIST_KEY],
(old?: { })
pageParams: Array<string | undefined> queryClient.setQueriesData<ConvoListQueryData>(
pages: Array<ChatBskyConvoListConvos.OutputSchema> {queryKey: [CONVO_LIST_KEY]},
}) => { old => {
if (!old) return old if (!old) return old
prevPages = old.pages
return { return {
...old, ...old,
pages: old.pages.map(page => { pages: old.pages.map(page => ({
return {
...page, ...page,
convos: page.convos.filter(convo => convo.id !== convoId), convos: page.convos.filter(convo => convo.id !== convoId),
} })),
}),
} }
}, },
) )
const prevRequestsQueries =
queryClient.getQueriesData<ConvoRequestListQueryData>({
queryKey: [REQUESTS_RQKEY_ROOT],
})
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => (convoId ? optimisticDeleteRequest(convoId, old) : old),
)
onMutate?.() onMutate?.()
return {prevPages} return {prevConvoListQueries, prevRequestsQueries}
}, },
onSuccess: data => { onSuccess: data => {
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
if (convoId) { if (convoId) {
void invalidateJoinLinkPreviewsForConvo(queryClient, convoId) void invalidateJoinLinkPreviewsForConvo(queryClient, convoId)
} }
@@ -80,41 +91,19 @@ export function useLeaveConvo(
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
logger.error(error) logger.error(error)
queryClient.setQueryData( if (context?.prevConvoListQueries) {
[CONVO_LIST_KEY], for (const [queryKey, prevData] of context.prevConvoListQueries) {
(old?: { queryClient.setQueryData(queryKey, prevData)
pageParams: Array<string | undefined> }
pages: Array<ChatBskyConvoListConvos.OutputSchema> }
}) => { if (context?.prevRequestsQueries) {
if (!old) return old for (const [queryKey, prevData] of context.prevRequestsQueries) {
return { queryClient.setQueryData(queryKey, prevData)
...old, }
pages: context?.prevPages || old.pages,
} }
},
)
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onError?.(error) onError?.(error)
}, },
}) })
} }
/**
* Gets currently pending and successful leave convo mutations
*
* @returns Array of `convoId`
*/
export function useLeftConvos() {
const pending = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'pending'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
const success = useMutationState({
filters: {mutationKey: [RQKEY_ROOT], status: 'success'},
select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
})
return useMemo(
() => [...pending, ...success].filter(id => id !== undefined),
[pending, success],
)
}
@@ -25,7 +25,6 @@ import {useAgeAssurance} from '#/ageAssurance'
import {type AgeAssuranceFlags} from '#/ageAssurance/types' import {type AgeAssuranceFlags} from '#/ageAssurance/types'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import {useLeftConvos} from './leave-conversation'
import { import {
type ConvoRequestListQueryData, type ConvoRequestListQueryData,
optimisticDelete as optimisticDeleteRequest, optimisticDelete as optimisticDeleteRequest,
@@ -136,7 +135,6 @@ export function ListConvosProviderInner({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {currentConvoId} = useCurrentConvoId() const {currentConvoId} = useCurrentConvoId()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const leftConvos = useLeftConvos()
const debouncedRefetch = useMemo(() => { const debouncedRefetch = useMemo(() => {
const refetchAndInvalidate = () => { const refetchAndInvalidate = () => {
@@ -664,15 +662,12 @@ export function ListConvosProviderInner({
]) ])
const ctx = useMemo(() => { const ctx = useMemo(() => {
const convos = const convos = data?.pages.flatMap(page => page.convos) ?? []
data?.pages
.flatMap(page => page.convos)
.filter(convo => !leftConvos.includes(convo.id)) ?? []
return { return {
accepted: convos.filter(conv => conv.status === 'accepted'), accepted: convos.filter(conv => conv.status === 'accepted'),
request: convos.filter(conv => conv.status === 'request'), request: convos.filter(conv => conv.status === 'request'),
} }
}, [data, leftConvos]) }, [data])
return ( return (
<ListConvosContext.Provider value={ctx}> <ListConvosContext.Provider value={ctx}>