diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index 430a03577f..015452749a 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -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 QueryClient} from '@tanstack/react-query' import {EventEmitter} from 'eventemitter3' @@ -54,22 +54,18 @@ const shadows: WeakMap< const emitter = new EventEmitter() type ShadowUpdateEventPayload = {did: string; shadow: Partial} -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) { - onUpdateRef.current(payload) - } - emitter.addListener('shadow-update', listener) - return () => { - emitter.removeListener('shadow-update', listener) - } - }, []) +/** + * 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< @@ -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): 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, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index a39e4b075f..5e5cf3111e 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -20,7 +20,12 @@ import { isNetworkError, } from '#/lib/strings/errors' 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 { ACTIVE_POLL_INTERVAL, BACKGROUND_POLL_INTERVAL, @@ -119,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 @@ -169,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', {}) @@ -497,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 @@ -528,6 +558,7 @@ export class Convo { this.relatedProfiles.set(member.did, member) } } + this.applyProfileShadows() } private updateConvo(convo: Partial) { @@ -538,6 +569,7 @@ export class Convo { for (const member of this.convo.members) { this.relatedProfiles.set(member.did, member) } + this.applyProfileShadows() } } @@ -708,6 +740,7 @@ export class Convo { this.relatedProfiles.set(member.did, member) } } while (cursor) + this.applyProfileShadows() } private fetchMessageHistoryError: {retry: () => void} | undefined @@ -754,6 +787,7 @@ export class Convo { for (const profile of relatedProfiles) { this.relatedProfiles.set(profile.did, profile) } + this.applyProfileShadows() } /* @@ -876,6 +910,7 @@ export class Convo { for (const profile of ev.relatedProfiles) { this.relatedProfiles.set(profile.did, profile) } + this.applyProfileShadows() } if ( @@ -1488,10 +1523,84 @@ export class Convo { } mergeProfileShadow(did: string, shadow: Partial) { - const related = this.relatedProfiles.get(did) - if (related) { - this.relatedProfiles.set(did, mergeShadow(related, shadow)) + // 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/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index 4732a898e6..14a297e6b9 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -11,7 +11,6 @@ import {useFocusEffect} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useAppState} from '#/lib/appState' -import {useOnUpdateProfileShadow} from '#/state/cache/profile-shadow' import {Convo} from '#/state/messages/convo/agent' import { type ConvoParams, @@ -113,10 +112,6 @@ export function ConvoProvider({ }, [isActive, convo, convoId, markAsRead]), ) - useOnUpdateProfileShadow(({did, shadow}) => { - convo.mergeProfileShadow(did, shadow) - }) - useEffect(() => { return convo.on(event => { switch (event.type) {