make convo agent shadow merge a persistent overlay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-10 16:03:23 +03:00
parent df6cb73284
commit 3fc5debf14
3 changed files with 160 additions and 27 deletions
+45 -16
View File
@@ -1,4 +1,4 @@
import {useEffect, useMemo, useRef, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api' import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query' import {type QueryClient} from '@tanstack/react-query'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
@@ -54,22 +54,18 @@ const shadows: WeakMap<
const emitter = new EventEmitter() const emitter = new EventEmitter()
type ShadowUpdateEventPayload = {did: string; shadow: Partial<ProfileShadow>} type ShadowUpdateEventPayload = {did: string; shadow: Partial<ProfileShadow>}
export function useOnUpdateProfileShadow(
onUpdate: (payload: ShadowUpdateEventPayload) => void,
) {
const onUpdateRef = useRef(onUpdate)
// eslint-disable-next-line react-hooks/refs
onUpdateRef.current = onUpdate
useEffect(() => { /**
function listener(payload: ShadowUpdateEventPayload) { * Subscribe to all profile shadow updates, regardless of did. Useful for
onUpdateRef.current(payload) * non-React consumers like the Convo agent. Returns an unlisten function.
} */
emitter.addListener('shadow-update', listener) export function listenProfileShadowUpdate(
return () => { listener: (payload: ShadowUpdateEventPayload) => void,
emitter.removeListener('shadow-update', listener) ): () => void {
} emitter.addListener('shadow-update', listener)
}, []) return () => {
emitter.removeListener('shadow-update', listener)
}
} }
export function useProfileShadow< export function useProfileShadow<
@@ -232,6 +228,39 @@ export function updateProfileShadow(
}) })
} }
/**
* 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<ProfileShadow>): 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<TProfileView extends bsky.profile.AnyProfileView>( export function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
profile: TProfileView, profile: TProfileView,
shadow: Partial<ProfileShadow>, shadow: Partial<ProfileShadow>,
+115 -6
View File
@@ -20,7 +20,12 @@ import {
isNetworkError, isNetworkError,
} from '#/lib/strings/errors' } from '#/lib/strings/errors'
import {Logger} from '#/logger' import {Logger} from '#/logger'
import {mergeShadow, type ProfileShadow} from '#/state/cache/profile-shadow' import {
isProfileShadowApplied,
listenProfileShadowUpdate,
mergeShadow,
type ProfileShadow,
} from '#/state/cache/profile-shadow'
import { import {
ACTIVE_POLL_INTERVAL, ACTIVE_POLL_INTERVAL,
BACKGROUND_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL,
@@ -119,6 +124,15 @@ export class Convo {
private deletedMessages: Set<string> = new Set() private deletedMessages: Set<string> = new Set()
private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> = private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> =
new 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<string, Partial<ProfileShadow>> = new Map()
private isProcessingPendingMessages = false private isProcessingPendingMessages = false
@@ -169,16 +183,29 @@ export class Convo {
private subscribers: (() => void)[] = [] private subscribers: (() => void)[] = []
subscribe(subscriber: () => 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) this.subscribers.push(subscriber)
return () => { return () => {
this.subscribers = this.subscribers.filter(s => s !== subscriber) 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 { getSnapshot(): ConvoState {
if (!this.snapshot) this.snapshot = this.generateSnapshot() if (!this.snapshot) this.snapshot = this.generateSnapshot()
// logger.debug('snapshotted', {}) // logger.debug('snapshotted', {})
@@ -497,6 +524,9 @@ export class Convo {
this.pendingMessages = new Map() this.pendingMessages = new Map()
this.deletedMessages = new Set() this.deletedMessages = new Set()
this.relatedProfiles = new Map() 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.pendingMessageFailure = null
this.fetchMessageHistoryError = undefined this.fetchMessageHistoryError = undefined
@@ -528,6 +558,7 @@ export class Convo {
this.relatedProfiles.set(member.did, member) this.relatedProfiles.set(member.did, member)
} }
} }
this.applyProfileShadows()
} }
private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) { private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) {
@@ -538,6 +569,7 @@ export class Convo {
for (const member of this.convo.members) { for (const member of this.convo.members) {
this.relatedProfiles.set(member.did, member) this.relatedProfiles.set(member.did, member)
} }
this.applyProfileShadows()
} }
} }
@@ -708,6 +740,7 @@ export class Convo {
this.relatedProfiles.set(member.did, member) this.relatedProfiles.set(member.did, member)
} }
} while (cursor) } while (cursor)
this.applyProfileShadows()
} }
private fetchMessageHistoryError: {retry: () => void} | undefined private fetchMessageHistoryError: {retry: () => void} | undefined
@@ -754,6 +787,7 @@ export class Convo {
for (const profile of relatedProfiles) { for (const profile of relatedProfiles) {
this.relatedProfiles.set(profile.did, profile) this.relatedProfiles.set(profile.did, profile)
} }
this.applyProfileShadows()
} }
/* /*
@@ -876,6 +910,7 @@ export class Convo {
for (const profile of ev.relatedProfiles) { for (const profile of ev.relatedProfiles) {
this.relatedProfiles.set(profile.did, profile) this.relatedProfiles.set(profile.did, profile)
} }
this.applyProfileShadows()
} }
if ( if (
@@ -1488,10 +1523,84 @@ export class Convo {
} }
mergeProfileShadow(did: string, shadow: Partial<ProfileShadow>) { mergeProfileShadow(did: string, shadow: Partial<ProfileShadow>) {
const related = this.relatedProfiles.get(did) // Accumulate even if the did isn't held yet - the profile may arrive
if (related) { // later via message history or the member list, and must get the shadow.
this.relatedProfiles.set(did, mergeShadow(related, shadow)) this.profileShadows.set(did, {
...this.profileShadows.get(did),
...shadow,
})
if (this.applyProfileShadow(did, shadow)) {
this.commit() 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<ProfileShadow>,
): 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<ProfileShadow>,
): 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,
}
}
} }
-5
View File
@@ -11,7 +11,6 @@ import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAppState} from '#/lib/appState' import {useAppState} from '#/lib/appState'
import {useOnUpdateProfileShadow} from '#/state/cache/profile-shadow'
import {Convo} from '#/state/messages/convo/agent' import {Convo} from '#/state/messages/convo/agent'
import { import {
type ConvoParams, type ConvoParams,
@@ -113,10 +112,6 @@ export function ConvoProvider({
}, [isActive, convo, convoId, markAsRead]), }, [isActive, convo, convoId, markAsRead]),
) )
useOnUpdateProfileShadow(({did, shadow}) => {
convo.mergeProfileShadow(did, shadow)
})
useEffect(() => { useEffect(() => {
return convo.on(event => { return convo.on(event => {
switch (event.type) { switch (event.type) {