extract chat log event handler for testability

Pure refactor: move the log-event handling out of the
ListConvosProviderInner effect closure into an exported
handleConvoLogEvents function, so tests can drive it directly with a
QueryClient and a batch of logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-12 10:52:06 +03:00
parent 40dcf8d853
commit 18194e2166
@@ -19,6 +19,7 @@ import throttle from 'lodash.throttle'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useCurrentConvoId} from '#/state/messages/current-convo-id' import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useMessagesEventBus} from '#/state/messages/events' import {useMessagesEventBus} from '#/state/messages/events'
import {type MessagesEventBusEvent} from '#/state/messages/events/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links' import {invalidateJoinLinkPreviewsForConvo} from '#/state/queries/join-links'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -207,7 +208,63 @@ export function ListConvosProviderInner({
const unsub = messagesBus.on( const unsub = messagesBus.on(
events => { events => {
if (events.type !== 'logs') return if (events.type !== 'logs') return
handleConvoLogEvents({
queryClient,
logs: events.logs,
currentConvoId,
currentAccountDid: currentAccount?.did,
onRefetchNeeded: debouncedRefetch,
})
},
{
// get events for all chats
convoId: undefined,
},
)
return () => unsub()
}, [
messagesBus,
currentConvoId,
queryClient,
currentAccount?.did,
debouncedRefetch,
])
const ctx = useMemo(() => {
const convos = data?.pages.flatMap(page => page.convos) ?? []
return {
accepted: convos.filter(conv => conv.status === 'accepted'),
request: convos.filter(conv => conv.status === 'request'),
}
}, [data])
return (
<ListConvosContext.Provider value={ctx}>
{children}
</ListConvosContext.Provider>
)
}
/**
* Applies a batch of chat log events to the React Query caches (convo lists,
* single-convo, members, requests). Exported as a pure function - separate
* from the event-bus subscription in ListConvosProviderInner - so it can be
* unit-tested directly without mounting the provider.
*/
export function handleConvoLogEvents({
queryClient,
logs,
currentConvoId,
currentAccountDid,
onRefetchNeeded,
}: {
queryClient: QueryClient
logs: Extract<MessagesEventBusEvent, {type: 'logs'}>['logs']
currentConvoId: string | undefined
currentAccountDid: string | undefined
onRefetchNeeded: () => void
}): void {
function mutateMembers( function mutateMembers(
convoId: string, convoId: string,
fn: ( fn: (
@@ -225,9 +282,7 @@ export function ListConvosProviderInner({
function updateConvoInAllLists( function updateConvoInAllLists(
convoId: string, convoId: string,
fn: ( fn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
convo: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView,
) { ) {
queryClient.setQueriesData<ConvoListQueryData>( queryClient.setQueriesData<ConvoListQueryData>(
{queryKey: [RQKEY_ROOT]}, {queryKey: [RQKEY_ROOT]},
@@ -241,9 +296,7 @@ export function ListConvosProviderInner({
function mutateConvoView( function mutateConvoView(
convoId: string, convoId: string,
fn: ( fn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
convo: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView,
) { ) {
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId), CONVO_KEY(convoId),
@@ -290,11 +343,7 @@ export function ListConvosProviderInner({
) )
} }
function handleMemberRemoved( function handleMemberRemoved(convoId: string, did: string, rev: string) {
convoId: string,
did: string,
rev: string,
) {
// If the optimistic remove already dropped them from the full // If the optimistic remove already dropped them from the full
// list, skip the memberCount decrement to avoid double-counting. // list, skip the memberCount decrement to avoid double-counting.
const alreadyRemovedMember = const alreadyRemovedMember =
@@ -312,9 +361,9 @@ export function ListConvosProviderInner({
) )
} }
for (const log of events.logs) { for (const log of logs) {
if (ChatBskyConvoDefs.isLogBeginConvo(log)) { if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) { } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
deleteConvoFromAllLists(log.convoId) deleteConvoFromAllLists(log.convoId)
// The viewer is no longer in this convo (they left on another // The viewer is no longer in this convo (they left on another
@@ -369,7 +418,7 @@ export function ListConvosProviderInner({
// the remaining logs in this batch still apply - the bus advances // the remaining logs in this batch still apply - the bus advances
// its cursor past this batch, so a dropped log is never // its cursor past this batch, so a dropped log is never
// redelivered. // redelivered.
debouncedRefetch() onRefetchNeeded()
continue continue
} }
@@ -382,9 +431,7 @@ export function ListConvosProviderInner({
} }
// add relatedProfiles to members list, but making sure to dedupe // add relatedProfiles to members list, but making sure to dedupe
const relatedProfilesSansMembers = ( const relatedProfilesSansMembers = (logRef.relatedProfiles ?? []).filter(
logRef.relatedProfiles ?? []
).filter(
profile => profile =>
!foundConvo.members.some(member => member.did === profile.did), !foundConvo.members.some(member => member.did === profile.did),
) )
@@ -399,7 +446,7 @@ export function ListConvosProviderInner({
foundConvo.id !== currentConvoId foundConvo.id !== currentConvoId
? (ChatBskyConvoDefs.isMessageView(logRef.message) || ? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) && ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
logRef.message.sender.did !== currentAccount?.did logRef.message.sender.did !== currentAccountDid
? foundConvo.unreadCount + 1 ? foundConvo.unreadCount + 1
: foundConvo.unreadCount : foundConvo.unreadCount
: 0, : 0,
@@ -418,10 +465,7 @@ export function ListConvosProviderInner({
if (i === 0) { if (i === 0) {
return { return {
...page, ...page,
convos: [ convos: [updatedConvo, ...filterConvoFromPage(page.convos)],
updatedConvo,
...filterConvoFromPage(page.convos),
],
} }
} }
return { return {
@@ -487,8 +531,7 @@ export function ListConvosProviderInner({
})), })),
) )
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) { } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
const requestQueries = const requestQueries = queryClient.getQueriesData<ConvoListQueryData>({
queryClient.getQueriesData<ConvoListQueryData>({
queryKey: RQKEY_PARTIAL('request'), queryKey: RQKEY_PARTIAL('request'),
}) })
let foundConvo: ChatBskyConvoDefs.ConvoView | null = null let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
@@ -501,7 +544,7 @@ export function ListConvosProviderInner({
// Use continue (not return) so the remaining logs in this batch // Use continue (not return) so the remaining logs in this batch
// still apply - the bus advances its cursor past this batch, so a // still apply - the bus advances its cursor past this batch, so a
// dropped log is never redelivered. // dropped log is never redelivered.
debouncedRefetch() onRefetchNeeded()
continue continue
} }
if (log.rev <= foundConvo.rev) { if (log.rev <= foundConvo.rev) {
@@ -545,7 +588,7 @@ export function ListConvosProviderInner({
}, },
(old?: ConvoListQueryData) => { (old?: ConvoListQueryData) => {
if (!old) { if (!old) {
debouncedRefetch() onRefetchNeeded()
return old return old
} }
return { return {
@@ -645,11 +688,11 @@ export function ListConvosProviderInner({
ChatBskyConvoDefs.isLogDisableJoinLink(log) ChatBskyConvoDefs.isLogDisableJoinLink(log)
) { ) {
// Join link data not included in the log event, trigger refetch to get it // Join link data not included in the log event, trigger refetch to get it
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogEditGroup(log)) { } else if (ChatBskyConvoDefs.isLogEditGroup(log)) {
// Updated group details (name etc.) aren't included in the log // Updated group details (name etc.) aren't included in the log
// event, so refetch to pick them up. // event, so refetch to pick them up.
debouncedRefetch() onRefetchNeeded()
} else if ( } else if (
ChatBskyConvoDefs.isLogApproveJoinRequest(log) || ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log) ChatBskyConvoDefs.isLogRejectJoinRequest(log)
@@ -693,7 +736,7 @@ export function ListConvosProviderInner({
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) { } else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
// Viewer isn't in the chat yet, but the inbox surfaces outgoing // Viewer isn't in the chat yet, but the inbox surfaces outgoing
// requests, so refetch to pick up the new entry. // requests, so refetch to pick up the new entry.
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) { } else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) {
// A requester rescinded their request to a group the viewer owns. // A requester rescinded their request to a group the viewer owns.
// Mirror of isLogIncomingJoinRequest: decrement the counts. // Mirror of isLogIncomingJoinRequest: decrement the counts.
@@ -715,9 +758,7 @@ export function ListConvosProviderInner({
log.convoId, log.convoId,
withRevGuard(log.rev, convo => { withRevGuard(log.rev, convo => {
// add relatedProfiles to members list, but making sure to dedupe // add relatedProfiles to members list, but making sure to dedupe
const relatedProfilesSansMembers = ( const relatedProfilesSansMembers = (log.relatedProfiles ?? []).filter(
log.relatedProfiles ?? []
).filter(
profile => profile =>
!convo.members.some(member => member.did === profile.did), !convo.members.some(member => member.did === profile.did),
) )
@@ -752,7 +793,7 @@ export function ListConvosProviderInner({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: CONVO_KEY(log.convoId), queryKey: CONVO_KEY(log.convoId),
}) })
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogRemoveMember(log)) { } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) {
const data = log.message.data const data = log.message.data
if ( if (
@@ -767,7 +808,7 @@ export function ListConvosProviderInner({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: CONVO_KEY(log.convoId), queryKey: CONVO_KEY(log.convoId),
}) })
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogMemberJoin(log)) { } else if (ChatBskyConvoDefs.isLogMemberJoin(log)) {
const data = log.message.data const data = log.message.data
if ( if (
@@ -786,7 +827,7 @@ export function ListConvosProviderInner({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: CONVO_KEY(log.convoId), queryKey: CONVO_KEY(log.convoId),
}) })
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogMemberLeave(log)) { } else if (ChatBskyConvoDefs.isLogMemberLeave(log)) {
const data = log.message.data const data = log.message.data
if ( if (
@@ -800,7 +841,7 @@ export function ListConvosProviderInner({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: CONVO_KEY(log.convoId), queryKey: CONVO_KEY(log.convoId),
}) })
debouncedRefetch() onRefetchNeeded()
} else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) { } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
queryClient.setQueriesData( queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]}, {queryKey: [RQKEY_ROOT]},
@@ -837,35 +878,6 @@ export function ListConvosProviderInner({
) )
} }
} }
},
{
// get events for all chats
convoId: undefined,
},
)
return () => unsub()
}, [
messagesBus,
currentConvoId,
queryClient,
currentAccount?.did,
debouncedRefetch,
])
const ctx = useMemo(() => {
const convos = data?.pages.flatMap(page => page.convos) ?? []
return {
accepted: convos.filter(conv => conv.status === 'accepted'),
request: convos.filter(conv => conv.status === 'request'),
}
}, [data])
return (
<ListConvosContext.Provider value={ctx}>
{children}
</ListConvosContext.Provider>
)
} }
export function useUnreadMessageCount() { export function useUnreadMessageCount() {