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' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} from '#/state/queries/find-contacts' import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' import { type FeedPage, findAllProfilesInQueryData as findAllProfilesInFeedsQueryData, } from '#/state/queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by' import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes' import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by' import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers' import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '#/state/queries/suggested-follows' import {findAllProfilesInQueryData as findAllProfilesInSuggestedUsersQueryData} from '#/state/queries/trending/useGetSuggestedUsersQuery' import {findAllProfilesInQueryData as findAllProfilesInPostThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' import type * as bsky from '#/types/bsky' import {useDeerVerificationProfileOverlay} from '../queries/deer-verification' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' export interface ProfileShadow { followingUri: string | undefined muted: boolean | undefined blockingUri: string | undefined verification: AppBskyActorDefs.VerificationState status: AppBskyActorDefs.StatusView | undefined activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined } const shadows: WeakMap< bsky.profile.AnyProfileView, Partial > = new WeakMap() const emitter = new EventEmitter() export function useProfileShadow< TProfileView extends bsky.profile.AnyProfileView, >(profile: TProfileView): Shadow { const [shadow, setShadow] = useState(() => shadows.get(profile)) const [prevPost, setPrevPost] = useState(profile) if (profile !== prevPost) { setPrevPost(profile) setShadow(shadows.get(profile)) } useEffect(() => { function onUpdate() { setShadow(shadows.get(profile)) } emitter.addListener(profile.did, onUpdate) return () => { emitter.removeListener(profile.did, onUpdate) } }, [profile]) const shadowed = useMemo(() => { if (shadow) { return mergeShadow(profile, shadow) } else { return castAsShadow(profile) } }, [profile, shadow]) return useDeerVerificationProfileOverlay(shadowed) } /** * Same as useProfileShadow, but allows for the profile to be undefined. * This is useful for when the profile is not guaranteed to be loaded yet. */ export function useMaybeProfileShadow< TProfileView extends bsky.profile.AnyProfileView, >(profile?: TProfileView): Shadow | undefined { const [shadow, setShadow] = useState(() => profile ? shadows.get(profile) : undefined, ) const [prevPost, setPrevPost] = useState(profile) if (profile !== prevPost) { setPrevPost(profile) setShadow(profile ? shadows.get(profile) : undefined) } useEffect(() => { if (!profile) return function onUpdate() { if (!profile) return setShadow(shadows.get(profile)) } emitter.addListener(profile.did, onUpdate) return () => { emitter.removeListener(profile.did, onUpdate) } }, [profile]) return useMemo(() => { if (!profile) return undefined if (shadow) { return mergeShadow(profile, shadow) } else { return castAsShadow(profile) } }, [profile, shadow]) } /** * Takes a list of posts, and returns a list of DIDs that should be filtered out * * Note: it doesn't retroactively scan the cache, but only listens to new updates. * The use case here is intended for removing a post from a feed after you mute the author */ export function usePostAuthorShadowFilter(data?: FeedPage[]) { const [trackedDids, setTrackedDids] = useState( () => data?.flatMap(page => page.slices.flatMap(slice => slice.items.map(item => item.post.author.did), ), ) ?? [], ) const [authors, setAuthors] = useState( new Map(), ) const [prevData, setPrevData] = useState(data) if (data !== prevData) { const newAuthors = new Set(trackedDids) let hasNew = false for (const slice of data?.flatMap(page => page.slices) ?? []) { for (const item of slice.items) { const author = item.post.author if (!newAuthors.has(author.did)) { hasNew = true newAuthors.add(author.did) } } } if (hasNew) setTrackedDids([...newAuthors]) setPrevData(data) } useEffect(() => { const unsubs: Array<() => void> = [] for (const did of trackedDids) { function onUpdate(value: Partial) { setAuthors(prev => { const prevValue = prev.get(did) const next = new Map(prev) next.set(did, { blocked: Boolean(value.blockingUri ?? prevValue?.blocked ?? false), muted: Boolean(value.muted ?? prevValue?.muted ?? false), }) return next }) } emitter.addListener(did, onUpdate) unsubs.push(() => { emitter.removeListener(did, onUpdate) }) } return () => { unsubs.map(fn => fn()) } }, [trackedDids]) return useMemo(() => { const dids: Array = [] for (const [did, value] of authors.entries()) { if (value.blocked || value.muted) { dids.push(did) } } return dids }, [authors]) } export function updateProfileShadow( queryClient: QueryClient, did: string, value: Partial, ) { const cachedProfiles = findProfilesInCache(queryClient, did) for (let profile of cachedProfiles) { shadows.set(profile, {...shadows.get(profile), ...value}) } batchedUpdates(() => { emitter.emit(did, value) }) } function mergeShadow( profile: TProfileView, shadow: Partial, ): Shadow { return castAsShadow({ ...profile, viewer: { ...(profile.viewer || {}), following: 'followingUri' in shadow ? shadow.followingUri : profile.viewer?.following, muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted, blocking: 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, activitySubscription: 'activitySubscription' in shadow ? shadow.activitySubscription : profile.viewer?.activitySubscription, }, verification: 'verification' in shadow ? shadow.verification : profile.verification, status: 'status' in shadow ? shadow.status : 'status' in profile ? profile.status : undefined, }) } function* findProfilesInCache( queryClient: QueryClient, did: string, ): Generator { yield* findAllProfilesInListMembersQueryData(queryClient, did) yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did) yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) yield* findAllProfilesInPostLikedByQueryData(queryClient, did) yield* findAllProfilesInPostRepostedByQueryData(queryClient, did) yield* findAllProfilesInPostQuotesQueryData(queryClient, did) yield* findAllProfilesInProfileQueryData(queryClient, did) yield* findAllProfilesInProfileFollowersQueryData(queryClient, did) yield* findAllProfilesInProfileFollowsQueryData(queryClient, did) yield* findAllProfilesInSuggestedUsersQueryData(queryClient, did) yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) yield* findAllProfilesInActorSearchQueryData(queryClient, did) yield* findAllProfilesInListConvosQueryData(queryClient, did) yield* findAllProfilesInFeedsQueryData(queryClient, did) yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) yield* findAllProfilesInNotifsQueryData(queryClient, did) yield* findAllProfilesInContactMatchesQueryData(queryClient, did) }