[Chat] Sync convo agent with shadow cache (#10827)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import {useEffect, useMemo} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -8,12 +8,10 @@ import Animated, {
|
|||||||
withDelay,
|
withDelay,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {
|
import {moderateProfile} from '@atproto/api'
|
||||||
moderateProfile,
|
|
||||||
type ModerationOpts,
|
|
||||||
type ModerationUI,
|
|
||||||
} from '@atproto/api'
|
|
||||||
|
|
||||||
|
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -33,7 +31,6 @@ export function AvatarBubbles({
|
|||||||
profiles: allProfiles,
|
profiles: allProfiles,
|
||||||
self = false,
|
self = false,
|
||||||
size = 120,
|
size = 120,
|
||||||
moderationOpts,
|
|
||||||
}: {
|
}: {
|
||||||
animate?: boolean
|
animate?: boolean
|
||||||
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
profiles: (bsky.profile.AnyProfileView | undefined)[]
|
||||||
@@ -45,19 +42,12 @@ export function AvatarBubbles({
|
|||||||
*/
|
*/
|
||||||
self?: boolean
|
self?: boolean
|
||||||
size?: number
|
size?: number
|
||||||
moderationOpts?: ModerationOpts
|
|
||||||
}) {
|
}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const profiles =
|
const profiles =
|
||||||
!self && allProfiles.length > 2
|
!self && allProfiles.length > 2
|
||||||
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
|
||||||
: allProfiles
|
: allProfiles
|
||||||
const moderations = useMemo(() => {
|
|
||||||
if (!moderationOpts) return []
|
|
||||||
return profiles.map(p => {
|
|
||||||
return p && moderateProfile(p, moderationOpts)
|
|
||||||
})
|
|
||||||
}, [profiles, moderationOpts])
|
|
||||||
|
|
||||||
const scale = size / 120
|
const scale = size / 120
|
||||||
const marginOffset = size < 120 ? -2 : 0
|
const marginOffset = size < 120 ? -2 : 0
|
||||||
@@ -110,7 +100,6 @@ export function AvatarBubbles({
|
|||||||
y={layout.y}
|
y={layout.y}
|
||||||
zIndex={layout.zIndex}
|
zIndex={layout.zIndex}
|
||||||
includeProfileBorder={layout.border}
|
includeProfileBorder={layout.border}
|
||||||
moderation={moderations[i]?.ui('avatar')}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -119,14 +108,13 @@ export function AvatarBubbles({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AvatarBubble({
|
function AvatarBubble({
|
||||||
profile,
|
profile: profileUnshadowed,
|
||||||
scale,
|
scale,
|
||||||
size,
|
size,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
zIndex,
|
zIndex,
|
||||||
includeProfileBorder,
|
includeProfileBorder,
|
||||||
moderation,
|
|
||||||
}: {
|
}: {
|
||||||
profile?: bsky.profile.AnyProfileView
|
profile?: bsky.profile.AnyProfileView
|
||||||
scale: SharedValue<number>
|
scale: SharedValue<number>
|
||||||
@@ -135,14 +123,15 @@ function AvatarBubble({
|
|||||||
y: number
|
y: number
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
includeProfileBorder?: boolean
|
includeProfileBorder?: boolean
|
||||||
moderation?: ModerationUI
|
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const profile = useMaybeProfileShadow(profileUnshadowed)
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
@@ -156,14 +145,14 @@ function AvatarBubble({
|
|||||||
zIndex != null && {zIndex},
|
zIndex != null && {zIndex},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}>
|
]}>
|
||||||
{profile ? (
|
{profile && moderationOpts ? (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={profile.avatar}
|
avatar={profile.avatar}
|
||||||
size={size}
|
size={size}
|
||||||
type="user"
|
type="user"
|
||||||
hideLiveBadge
|
hideLiveBadge
|
||||||
noBorder
|
noBorder
|
||||||
moderation={moderation}
|
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AvatarPlaceholder size={size} />
|
<AvatarPlaceholder size={size} />
|
||||||
|
|||||||
@@ -152,11 +152,7 @@ function RecentChatItem({
|
|||||||
a.align_center,
|
a.align_center,
|
||||||
]}>
|
]}>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles
|
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||||
profiles={convo.members}
|
|
||||||
size={WIDTH - 8}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={primaryProfile.avatar}
|
avatar={primaryProfile.avatar}
|
||||||
|
|||||||
@@ -514,11 +514,7 @@ function ExistingChatCard({
|
|||||||
]}>
|
]}>
|
||||||
<ProfileCard.Header>
|
<ProfileCard.Header>
|
||||||
{convo.kind === 'group' ? (
|
{convo.kind === 'group' ? (
|
||||||
<AvatarBubbles
|
<AvatarBubbles profiles={convo.members} size={40} />
|
||||||
profiles={convo.members}
|
|
||||||
size={40}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ProfileCard.Avatar
|
<ProfileCard.Avatar
|
||||||
profile={convo.primaryMember}
|
profile={convo.primaryMember}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function MessagesListHeader({convo}: {convo?: ConvoWithDetails | null}) {
|
|||||||
convo.kind === 'direct' ? (
|
convo.kind === 'direct' ? (
|
||||||
<ProfileHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
<ProfileHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
||||||
) : (
|
) : (
|
||||||
<GroupHeaderReady convo={convo} moderationOpts={moderationOpts} />
|
<GroupHeaderReady convo={convo} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -152,10 +152,8 @@ function ProfileHeaderReady({
|
|||||||
|
|
||||||
function GroupHeaderReady({
|
function GroupHeaderReady({
|
||||||
convo,
|
convo,
|
||||||
moderationOpts,
|
|
||||||
}: {
|
}: {
|
||||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||||
moderationOpts: ModerationOpts
|
|
||||||
}) {
|
}) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
|
||||||
@@ -178,11 +176,7 @@ function GroupHeaderReady({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
<AvatarBubbles
|
<AvatarBubbles size={40} profiles={convo.members} />
|
||||||
size={40}
|
|
||||||
profiles={convo.members}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
<View style={[a.flex_row, a.flex_1, a.align_center]}>
|
<View style={[a.flex_row, a.flex_1, a.align_center]}>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
||||||
|
|||||||
@@ -461,10 +461,7 @@ function SettingsHeader({
|
|||||||
<View
|
<View
|
||||||
style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
|
style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
|
||||||
<View style={[a.align_center, a.justify_center]}>
|
<View style={[a.align_center, a.justify_center]}>
|
||||||
<AvatarBubbles
|
<AvatarBubbles profiles={convo.members} />
|
||||||
profiles={convo.members}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ function GroupChatItem({
|
|||||||
<AvatarBubbles
|
<AvatarBubbles
|
||||||
profiles={convo.members}
|
profiles={convo.members}
|
||||||
size={isWithinLeftPanel ? 48 : 52}
|
size={isWithinLeftPanel ? 48 : 52}
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
title={chatName}
|
title={chatName}
|
||||||
|
|||||||
@@ -87,11 +87,7 @@ export function MessagesListGroupInfoPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={[a.align_center, a.justify_center]}>
|
<View style={[a.align_center, a.justify_center]}>
|
||||||
<AvatarBubbles
|
<AvatarBubbles animate={true} profiles={convo.members} />
|
||||||
animate={true}
|
|
||||||
profiles={convo.members}
|
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
|
||||||
{convo.details.name ? (
|
{convo.details.name ? (
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}
|
style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
||||||
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
|
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
|
||||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
@@ -26,8 +25,6 @@ export function OutgoingRequestListItem({
|
|||||||
|
|
||||||
const prompt = Prompt.usePromptControl()
|
const prompt = Prompt.usePromptControl()
|
||||||
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
|
|
||||||
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
|
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
|
||||||
useWithdrawJoinGroupChatRequest({
|
useWithdrawJoinGroupChatRequest({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -73,7 +70,6 @@ export function OutgoingRequestListItem({
|
|||||||
).fill(undefined),
|
).fill(undefined),
|
||||||
]}
|
]}
|
||||||
size={48}
|
size={48}
|
||||||
moderationOpts={moderationOpts}
|
|
||||||
/>
|
/>
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
Vendored
+53
-1
@@ -53,6 +53,21 @@ const shadows: WeakMap<
|
|||||||
> = new WeakMap()
|
> = new WeakMap()
|
||||||
const emitter = new EventEmitter()
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
|
type ShadowUpdateEventPayload = {did: string; shadow: Partial<ProfileShadow>}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<
|
export function useProfileShadow<
|
||||||
TProfileView extends bsky.profile.AnyProfileView,
|
TProfileView extends bsky.profile.AnyProfileView,
|
||||||
>(profile: TProfileView): Shadow<TProfileView> {
|
>(profile: TProfileView): Shadow<TProfileView> {
|
||||||
@@ -206,10 +221,47 @@ export function updateProfileShadow(
|
|||||||
}
|
}
|
||||||
batchedUpdates(() => {
|
batchedUpdates(() => {
|
||||||
emitter.emit(did, value)
|
emitter.emit(did, value)
|
||||||
|
emitter.emit('shadow-update', {
|
||||||
|
did,
|
||||||
|
shadow: value,
|
||||||
|
} satisfies ShadowUpdateEventPayload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
|
/**
|
||||||
|
* 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>(
|
||||||
profile: TProfileView,
|
profile: TProfileView,
|
||||||
shadow: Partial<ProfileShadow>,
|
shadow: Partial<ProfileShadow>,
|
||||||
): Shadow<TProfileView> {
|
): Shadow<TProfileView> {
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
isNetworkError,
|
isNetworkError,
|
||||||
} from '#/lib/strings/errors'
|
} from '#/lib/strings/errors'
|
||||||
import {Logger} from '#/logger'
|
import {Logger} from '#/logger'
|
||||||
|
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,
|
||||||
@@ -118,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
|
||||||
|
|
||||||
@@ -168,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', {})
|
||||||
@@ -496,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
|
||||||
@@ -527,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>) {
|
||||||
@@ -537,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,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
|
||||||
@@ -753,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -875,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 (
|
||||||
@@ -1485,4 +1521,86 @@ export class Convo {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mergeProfileShadow(did: string, shadow: Partial<ProfileShadow>) {
|
||||||
|
// 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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export function useChatActorStatusQuery() {
|
|||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
gcTime: STALE.INFINITY,
|
||||||
|
staleTime: STALE.SECONDS.FIFTEEN,
|
||||||
queryKey: chatActorStatusQueryKey(),
|
queryKey: chatActorStatusQueryKey(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const {data} = await agent.chat.bsky.actor.getStatus(
|
const {data} = await agent.chat.bsky.actor.getStatus(
|
||||||
@@ -21,7 +23,5 @@ export function useChatActorStatusQuery() {
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
staleTime: STALE.INFINITY,
|
|
||||||
gcTime: STALE.INFINITY,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,6 +528,10 @@ export function useProfileBlockMutationQueue(
|
|||||||
updateProfileShadow(queryClient, did, {
|
updateProfileShadow(queryClient, did, {
|
||||||
blockingUri: finalBlockingUri,
|
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]})
|
void queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user