my fork of the bluesky client
at main 157 lines 4.5 kB view raw
1import {useEffect, useMemo, useState} from 'react' 2import { 3 AppBskyEmbedRecord, 4 AppBskyEmbedRecordWithMedia, 5 AppBskyFeedDefs, 6} from '@atproto/api' 7import {QueryClient} from '@tanstack/react-query' 8import EventEmitter from 'eventemitter3' 9 10import {batchedUpdates} from '#/lib/batchedUpdates' 11import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed' 12import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed' 13import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '../queries/post-quotes' 14import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread' 15import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts' 16import {castAsShadow, Shadow} from './types' 17export type {Shadow} from './types' 18 19export interface PostShadow { 20 likeUri: string | undefined 21 repostUri: string | undefined 22 isDeleted: boolean 23 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 24 pinned: boolean 25} 26 27export const POST_TOMBSTONE = Symbol('PostTombstone') 28 29const emitter = new EventEmitter() 30const shadows: WeakMap< 31 AppBskyFeedDefs.PostView, 32 Partial<PostShadow> 33> = new WeakMap() 34 35export function usePostShadow( 36 post: AppBskyFeedDefs.PostView, 37): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { 38 const [shadow, setShadow] = useState(() => shadows.get(post)) 39 const [prevPost, setPrevPost] = useState(post) 40 if (post !== prevPost) { 41 setPrevPost(post) 42 setShadow(shadows.get(post)) 43 } 44 45 useEffect(() => { 46 function onUpdate() { 47 setShadow(shadows.get(post)) 48 } 49 emitter.addListener(post.uri, onUpdate) 50 return () => { 51 emitter.removeListener(post.uri, onUpdate) 52 } 53 }, [post, setShadow]) 54 55 return useMemo(() => { 56 if (shadow) { 57 return mergeShadow(post, shadow) 58 } else { 59 return castAsShadow(post) 60 } 61 }, [post, shadow]) 62} 63 64function mergeShadow( 65 post: AppBskyFeedDefs.PostView, 66 shadow: Partial<PostShadow>, 67): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { 68 if (shadow.isDeleted) { 69 return POST_TOMBSTONE 70 } 71 72 let likeCount = post.likeCount ?? 0 73 if ('likeUri' in shadow) { 74 const wasLiked = !!post.viewer?.like 75 const isLiked = !!shadow.likeUri 76 if (wasLiked && !isLiked) { 77 likeCount-- 78 } else if (!wasLiked && isLiked) { 79 likeCount++ 80 } 81 likeCount = Math.max(0, likeCount) 82 } 83 84 let repostCount = post.repostCount ?? 0 85 if ('repostUri' in shadow) { 86 const wasReposted = !!post.viewer?.repost 87 const isReposted = !!shadow.repostUri 88 if (wasReposted && !isReposted) { 89 repostCount-- 90 } else if (!wasReposted && isReposted) { 91 repostCount++ 92 } 93 repostCount = Math.max(0, repostCount) 94 } 95 96 let embed: typeof post.embed 97 if ('embed' in shadow) { 98 if ( 99 (AppBskyEmbedRecord.isView(post.embed) && 100 AppBskyEmbedRecord.isView(shadow.embed)) || 101 (AppBskyEmbedRecordWithMedia.isView(post.embed) && 102 AppBskyEmbedRecordWithMedia.isView(shadow.embed)) 103 ) { 104 embed = shadow.embed 105 } 106 } 107 108 return castAsShadow({ 109 ...post, 110 embed: embed || post.embed, 111 likeCount: likeCount, 112 repostCount: repostCount, 113 viewer: { 114 ...(post.viewer || {}), 115 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, 116 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, 117 pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, 118 }, 119 }) 120} 121 122export function updatePostShadow( 123 queryClient: QueryClient, 124 uri: string, 125 value: Partial<PostShadow>, 126) { 127 const cachedPosts = findPostsInCache(queryClient, uri) 128 for (let post of cachedPosts) { 129 shadows.set(post, {...shadows.get(post), ...value}) 130 } 131 batchedUpdates(() => { 132 emitter.emit(uri) 133 }) 134} 135 136function* findPostsInCache( 137 queryClient: QueryClient, 138 uri: string, 139): Generator<AppBskyFeedDefs.PostView, void> { 140 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { 141 yield post 142 } 143 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 144 yield post 145 } 146 for (let node of findAllPostsInThreadQueryData(queryClient, uri)) { 147 if (node.type === 'post') { 148 yield node.post 149 } 150 } 151 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 152 yield post 153 } 154 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 155 yield post 156 } 157}