import {useEffect, useMemo, useState} from 'react' import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, type AppBskyFeedDefs, } from '@atproto/api' import {type QueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' import {useProfileShadow} from './profile-shadow' import {castAsShadow, type Shadow} from './types' export type {Shadow} from './types' export interface PostShadow { likeUri: string | undefined repostUri: string | undefined isDeleted: boolean embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined pinned: boolean } export const POST_TOMBSTONE = Symbol('PostTombstone') const emitter = new EventEmitter() const shadows: WeakMap< AppBskyFeedDefs.PostView, Partial > = new WeakMap() export function usePostShadow( post: AppBskyFeedDefs.PostView, ): Shadow | typeof POST_TOMBSTONE { const [shadow, setShadow] = useState(() => shadows.get(post)) const [prevPost, setPrevPost] = useState(post) if (post !== prevPost) { setPrevPost(post) setShadow(shadows.get(post)) } const authorShadow = useProfileShadow(post.author) const wasMuted = !!authorShadow.viewer?.muted const wasBlocked = !!authorShadow.viewer?.blocking useEffect(() => { function onUpdate() { setShadow(shadows.get(post)) } emitter.addListener(post.uri, onUpdate) return () => { emitter.removeListener(post.uri, onUpdate) } }, [post, setShadow]) return useMemo(() => { if (wasMuted || wasBlocked) { return POST_TOMBSTONE } if (shadow) { return mergeShadow(post, shadow) } else { return castAsShadow(post) } }, [post, shadow, wasMuted, wasBlocked]) } export function mergeShadow( post: AppBskyFeedDefs.PostView, shadow: Partial, ): Shadow | typeof POST_TOMBSTONE { if (shadow.isDeleted) { return POST_TOMBSTONE } let likeCount = post.likeCount ?? 0 if ('likeUri' in shadow) { const wasLiked = !!post.viewer?.like const isLiked = !!shadow.likeUri if (wasLiked && !isLiked) { likeCount-- } else if (!wasLiked && isLiked) { likeCount++ } likeCount = Math.max(0, likeCount) } let repostCount = post.repostCount ?? 0 if ('repostUri' in shadow) { const wasReposted = !!post.viewer?.repost const isReposted = !!shadow.repostUri if (wasReposted && !isReposted) { repostCount-- } else if (!wasReposted && isReposted) { repostCount++ } repostCount = Math.max(0, repostCount) } let embed: typeof post.embed if ('embed' in shadow) { if ( (AppBskyEmbedRecord.isView(post.embed) && AppBskyEmbedRecord.isView(shadow.embed)) || (AppBskyEmbedRecordWithMedia.isView(post.embed) && AppBskyEmbedRecordWithMedia.isView(shadow.embed)) ) { embed = shadow.embed } } return castAsShadow({ ...post, embed: embed || post.embed, likeCount: likeCount, repostCount: repostCount, viewer: { ...(post.viewer || {}), like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, }, }) } export function updatePostShadow( queryClient: QueryClient, uri: string, value: Partial, ) { const cachedPosts = findPostsInCache(queryClient, uri) for (let post of cachedPosts) { shadows.set(post, {...shadows.get(post), ...value}) } batchedUpdates(() => { emitter.emit(uri) }) } function* findPostsInCache( queryClient: QueryClient, uri: string, ): Generator { for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { yield post } for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { yield post } for (let node of findAllPostsInThreadQueryData(queryClient, uri)) { if (node.type === 'post') { yield node.post } } for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { yield post } for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { yield post } for (let post of findAllPostsInExploreFeedPreviewsQueryData( queryClient, uri, )) { yield post } }