From 8b4a5238bed7e9282386846069a298cdf2483a1d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 10 Jun 2026 10:16:58 -0500 Subject: [PATCH] [Chat] Sync convo agent with shadow cache (#10827) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Samuel Newman --- src/components/AvatarBubbles.tsx | 31 ++--- .../PostControls/ShareMenu/RecentChats.tsx | 6 +- .../dialogs/SearchablePeopleList.tsx | 6 +- src/components/dms/MessagesListHeader.tsx | 10 +- .../Messages/ConversationSettings/index.tsx | 5 +- .../Messages/components/ChatListItem.tsx | 1 - .../components/MessagesListGroupInfoPanel.tsx | 6 +- .../components/OutgoingRequestListItem.tsx | 4 - src/state/cache/profile-shadow.ts | 54 +++++++- src/state/messages/convo/agent.ts | 122 +++++++++++++++++- src/state/queries/messages/get-status.ts | 4 +- src/state/queries/profile.ts | 4 + 12 files changed, 195 insertions(+), 58 deletions(-) diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx index 2f3fb8b15d..894e1f34f5 100644 --- a/src/components/AvatarBubbles.tsx +++ b/src/components/AvatarBubbles.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo} from 'react' +import {useEffect} from 'react' import {View} from 'react-native' import Animated, { Easing, @@ -8,12 +8,10 @@ import Animated, { withDelay, withTiming, } from 'react-native-reanimated' -import { - moderateProfile, - type ModerationOpts, - type ModerationUI, -} from '@atproto/api' +import {moderateProfile} from '@atproto/api' +import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' @@ -33,7 +31,6 @@ export function AvatarBubbles({ profiles: allProfiles, self = false, size = 120, - moderationOpts, }: { animate?: boolean profiles: (bsky.profile.AnyProfileView | undefined)[] @@ -45,19 +42,12 @@ export function AvatarBubbles({ */ self?: boolean size?: number - moderationOpts?: ModerationOpts }) { const {currentAccount} = useSession() const profiles = !self && allProfiles.length > 2 ? allProfiles.filter(p => !p || p.did !== currentAccount?.did) : allProfiles - const moderations = useMemo(() => { - if (!moderationOpts) return [] - return profiles.map(p => { - return p && moderateProfile(p, moderationOpts) - }) - }, [profiles, moderationOpts]) const scale = size / 120 const marginOffset = size < 120 ? -2 : 0 @@ -110,7 +100,6 @@ export function AvatarBubbles({ y={layout.y} zIndex={layout.zIndex} includeProfileBorder={layout.border} - moderation={moderations[i]?.ui('avatar')} /> ))} @@ -119,14 +108,13 @@ export function AvatarBubbles({ } function AvatarBubble({ - profile, + profile: profileUnshadowed, scale, size, x, y, zIndex, includeProfileBorder, - moderation, }: { profile?: bsky.profile.AnyProfileView scale: SharedValue @@ -135,14 +123,15 @@ function AvatarBubble({ y: number zIndex?: number includeProfileBorder?: boolean - moderation?: ModerationUI }) { const t = useTheme() - const animatedStyle = useAnimatedStyle(() => ({ transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}], })) + const profile = useMaybeProfileShadow(profileUnshadowed) + const moderationOpts = useModerationOpts() + return ( - {profile ? ( + {profile && moderationOpts ? ( ) : ( diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 6107fa5755..bab3cbfded 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -152,11 +152,7 @@ function RecentChatItem({ a.align_center, ]}> {convo.kind === 'group' ? ( - + ) : ( {convo.kind === 'group' ? ( - + ) : ( ) : ( - + ) ) : ( <> @@ -152,10 +152,8 @@ function ProfileHeaderReady({ function GroupHeaderReady({ convo, - moderationOpts, }: { convo: Extract - moderationOpts: ModerationOpts }) { const {t: l} = useLingui() @@ -178,11 +176,7 @@ function GroupHeaderReady({ }, } }> - + - + {isOwner ? ( } title={chatName} diff --git a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx index 8428b1a4b2..b1bb4ad976 100644 --- a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx +++ b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx @@ -87,11 +87,7 @@ export function MessagesListGroupInfoPanel({ return ( <> - + {convo.details.name ? ( { @@ -73,7 +70,6 @@ export function OutgoingRequestListItem({ ).fill(undefined), ]} size={48} - moderationOpts={moderationOpts} /> = new WeakMap() const emitter = new EventEmitter() +type ShadowUpdateEventPayload = {did: string; shadow: Partial} + +/** + * Subscribe to all profile shadow updates, regardless of did. Useful for + * non-React consumers like the Convo agent. Returns an unlisten function. + */ +export function listenProfileShadowUpdate( + listener: (payload: ShadowUpdateEventPayload) => void, +): () => void { + emitter.addListener('shadow-update', listener) + return () => { + emitter.removeListener('shadow-update', listener) + } +} + export function useProfileShadow< TProfileView extends bsky.profile.AnyProfileView, >(profile: TProfileView): Shadow { @@ -206,10 +221,47 @@ export function updateProfileShadow( } batchedUpdates(() => { emitter.emit(did, value) + emitter.emit('shadow-update', { + did, + shadow: value, + } satisfies ShadowUpdateEventPayload) }) } -function mergeShadow( +/** + * Returns true if merging `shadow` into `profile` would change nothing, i.e. + * `mergeShadow` would be a no-op. Object-valued fields are compared by + * reference, so this can return false negatives - callers may do redundant + * merges, but never skip a real change. + */ +export function isProfileShadowApplied< + TProfileView extends bsky.profile.AnyProfileView, +>(profile: TProfileView, shadow: Partial): boolean { + if ('followingUri' in shadow) { + if (profile.viewer?.following !== shadow.followingUri) return false + } + if ('muted' in shadow) { + if (profile.viewer?.muted !== shadow.muted) return false + } + if ('blockingUri' in shadow) { + if (profile.viewer?.blocking !== shadow.blockingUri) return false + } + if ('activitySubscription' in shadow) { + if (profile.viewer?.activitySubscription !== shadow.activitySubscription) { + return false + } + } + if ('verification' in shadow) { + if (profile.verification !== shadow.verification) return false + } + if ('status' in shadow) { + const current = 'status' in profile ? profile.status : undefined + if (current !== shadow.status) return false + } + return true +} + +export function mergeShadow( profile: TProfileView, shadow: Partial, ): Shadow { diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index e89965eb45..5e5cf3111e 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -20,6 +20,12 @@ import { isNetworkError, } from '#/lib/strings/errors' import {Logger} from '#/logger' +import { + isProfileShadowApplied, + listenProfileShadowUpdate, + mergeShadow, + type ProfileShadow, +} from '#/state/cache/profile-shadow' import { ACTIVE_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL, @@ -118,6 +124,15 @@ export class Convo { private deletedMessages: Set = new Set() private relatedProfiles: Map = new Map() + /** + * Accumulated profile shadow state, keyed by did. The profiles this agent + * holds come from direct service fetches, so they are invisible to the + * shadow cache's react-query scan. We keep our own overlay and re-apply it + * whenever server data overwrites `relatedProfiles` or `convo.members`, + * otherwise refreshes would revert optimistic state (e.g. a block) until + * the server catches up. + */ + private profileShadows: Map> = new Map() private isProcessingPendingMessages = false @@ -168,16 +183,29 @@ export class Convo { private subscribers: (() => void)[] = [] subscribe(subscriber: () => void) { - if (this.subscribers.length === 0) this.init() + if (this.subscribers.length === 0) { + this.cleanupProfileShadowListener = listenProfileShadowUpdate( + ({did, shadow}) => { + this.mergeProfileShadow(did, shadow) + }, + ) + this.init() + } this.subscribers.push(subscriber) return () => { this.subscribers = this.subscribers.filter(s => s !== subscriber) - if (this.subscribers.length === 0) this.suspend() + if (this.subscribers.length === 0) { + this.cleanupProfileShadowListener?.() + this.cleanupProfileShadowListener = undefined + this.suspend() + } } } + private cleanupProfileShadowListener: (() => void) | undefined + getSnapshot(): ConvoState { if (!this.snapshot) this.snapshot = this.generateSnapshot() // logger.debug('snapshotted', {}) @@ -496,6 +524,9 @@ export class Convo { this.pendingMessages = new Map() this.deletedMessages = new Set() this.relatedProfiles = new Map() + // Shadow updates fired while suspended are missed, so the overlay may be + // stale - drop it and trust the from-scratch refetch. + this.profileShadows = new Map() this.pendingMessageFailure = null this.fetchMessageHistoryError = undefined @@ -527,6 +558,7 @@ export class Convo { this.relatedProfiles.set(member.did, member) } } + this.applyProfileShadows() } private updateConvo(convo: Partial) { @@ -537,6 +569,7 @@ export class Convo { for (const member of this.convo.members) { this.relatedProfiles.set(member.did, member) } + this.applyProfileShadows() } } @@ -707,6 +740,7 @@ export class Convo { this.relatedProfiles.set(member.did, member) } } while (cursor) + this.applyProfileShadows() } private fetchMessageHistoryError: {retry: () => void} | undefined @@ -753,6 +787,7 @@ export class Convo { for (const profile of relatedProfiles) { this.relatedProfiles.set(profile.did, profile) } + this.applyProfileShadows() } /* @@ -875,6 +910,7 @@ export class Convo { for (const profile of ev.relatedProfiles) { this.relatedProfiles.set(profile.did, profile) } + this.applyProfileShadows() } if ( @@ -1485,4 +1521,86 @@ export class Convo { throw error } } + + mergeProfileShadow(did: string, shadow: Partial) { + // Accumulate even if the did isn't held yet - the profile may arrive + // later via message history or the member list, and must get the shadow. + this.profileShadows.set(did, { + ...this.profileShadows.get(did), + ...shadow, + }) + if (this.applyProfileShadow(did, shadow)) { + this.commit() + } + } + + /** + * Re-applies all accumulated shadows. Must be called after any server data + * lands in `relatedProfiles` or `this.convo`, since raw server profiles + * would otherwise clobber optimistic state. + */ + private applyProfileShadows() { + for (const [did, shadow] of this.profileShadows) { + this.applyProfileShadow(did, shadow) + } + } + + private applyProfileShadow( + did: string, + shadow: Partial, + ): boolean { + let changed = false + + const related = this.relatedProfiles.get(did) + if (related && !isProfileShadowApplied(related, shadow)) { + this.relatedProfiles.set(did, mergeShadow(related, shadow)) + changed = true + } + + if (this.convo) { + const next = applyShadowToConvo(this.convo, did, shadow) + if (next) { + this.convo = next + changed = true + } + } + + return changed + } +} + +/** + * Returns a new convo with the shadow merged into the matching member (and + * `primaryMember`, if it's the same profile), or null if nothing changed. + */ +function applyShadowToConvo( + convo: ConvoWithDetails, + did: string, + shadow: Partial, +): ConvoWithDetails | null { + const i = convo.members.findIndex(m => m.did === did) + if (i === -1) return null + if (isProfileShadowApplied(convo.members[i], shadow)) return null + + // The branches are identical, but narrowing the union is what lets the + // member arrays keep their per-kind types. + if (convo.kind === 'group') { + const members = convo.members.slice() + members[i] = mergeShadow(members[i], shadow) + return { + ...convo, + members, + primaryMember: + convo.primaryMember?.did === did ? members[i] : convo.primaryMember, + } + } else { + const members = convo.members.slice() + members[i] = mergeShadow(members[i], shadow) + return { + ...convo, + members, + primaryMember: + convo.primaryMember.did === did ? members[i] : convo.primaryMember, + } + } } diff --git a/src/state/queries/messages/get-status.ts b/src/state/queries/messages/get-status.ts index 2bfb12550a..89625dca24 100644 --- a/src/state/queries/messages/get-status.ts +++ b/src/state/queries/messages/get-status.ts @@ -12,6 +12,8 @@ export function useChatActorStatusQuery() { const agent = useAgent() return useQuery({ + gcTime: STALE.INFINITY, + staleTime: STALE.SECONDS.FIFTEEN, queryKey: chatActorStatusQueryKey(), queryFn: async () => { const {data} = await agent.chat.bsky.actor.getStatus( @@ -21,7 +23,5 @@ export function useChatActorStatusQuery() { return data }, - staleTime: STALE.INFINITY, - gcTime: STALE.INFINITY, }) } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 7db15d0bc3..526a9df8f3 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -528,6 +528,10 @@ export function useProfileBlockMutationQueue( updateProfileShadow(queryClient, did, { blockingUri: finalBlockingUri, }) + // The shadow only reaches components that read profiles through shadow + // hooks. The convo list is also read raw (e.g. the unread badge's + // calculateCount, getMessageInfo), and blocks emit no chat log event, + // so without a refetch that data stays stale indefinitely. void queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) }, })