Bluesky app fork with some witchin' additions 馃挮
at main 197 lines 5.9 kB view raw
1import {useEffect, useMemo, useState} from 'react' 2import { 3 AppBskyEmbedRecord, 4 AppBskyEmbedRecordWithMedia, 5 type AppBskyFeedDefs, 6} from '@atproto/api' 7import {type QueryClient} from '@tanstack/react-query' 8import EventEmitter from 'eventemitter3' 9 10import {batchedUpdates} from '#/lib/batchedUpdates' 11import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery' 12import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 13import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed' 14import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed' 15import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' 16import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts' 17import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache' 18import {castAsShadow, type Shadow} from './types' 19export type {Shadow} from './types' 20 21export interface PostShadow { 22 likeUri: string | undefined 23 repostUri: string | undefined 24 isDeleted: boolean 25 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 26 pinned: boolean 27 optimisticReplyCount: number | undefined 28 bookmarked: boolean | undefined 29} 30 31export const POST_TOMBSTONE = Symbol('PostTombstone') 32 33const emitter = new EventEmitter() 34const shadows: WeakMap< 35 AppBskyFeedDefs.PostView, 36 Partial<PostShadow> 37> = new WeakMap() 38 39/** 40 * Use with caution! This function returns the raw shadow data for a post. 41 * Prefer using `usePostShadow`. 42 */ 43export function dangerousGetPostShadow(post: AppBskyFeedDefs.PostView) { 44 return shadows.get(post) 45} 46 47export function usePostShadow( 48 post: AppBskyFeedDefs.PostView, 49): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { 50 const [shadow, setShadow] = useState(() => shadows.get(post)) 51 const [prevPost, setPrevPost] = useState(post) 52 if (post !== prevPost) { 53 setPrevPost(post) 54 setShadow(shadows.get(post)) 55 } 56 57 useEffect(() => { 58 function onUpdate() { 59 setShadow(shadows.get(post)) 60 } 61 emitter.addListener(post.uri, onUpdate) 62 return () => { 63 emitter.removeListener(post.uri, onUpdate) 64 } 65 }, [post, setShadow]) 66 67 return useMemo(() => { 68 if (shadow) { 69 return mergeShadow(post, shadow) 70 } else { 71 return castAsShadow(post) 72 } 73 }, [post, shadow]) 74} 75 76function mergeShadow( 77 post: AppBskyFeedDefs.PostView, 78 shadow: Partial<PostShadow>, 79): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { 80 if (shadow.isDeleted) { 81 return POST_TOMBSTONE 82 } 83 84 let likeCount = post.likeCount ?? 0 85 if ('likeUri' in shadow) { 86 const wasLiked = !!post.viewer?.like 87 const isLiked = !!shadow.likeUri 88 if (wasLiked && !isLiked) { 89 likeCount-- 90 } else if (!wasLiked && isLiked) { 91 likeCount++ 92 } 93 likeCount = Math.max(0, likeCount) 94 } 95 96 let bookmarkCount = post.bookmarkCount ?? 0 97 if ('bookmarked' in shadow) { 98 const wasBookmarked = !!post.viewer?.bookmarked 99 const isBookmarked = !!shadow.bookmarked 100 if (wasBookmarked && !isBookmarked) { 101 bookmarkCount-- 102 } else if (!wasBookmarked && isBookmarked) { 103 bookmarkCount++ 104 } 105 bookmarkCount = Math.max(0, bookmarkCount) 106 } 107 108 let repostCount = post.repostCount ?? 0 109 if ('repostUri' in shadow) { 110 const wasReposted = !!post.viewer?.repost 111 const isReposted = !!shadow.repostUri 112 if (wasReposted && !isReposted) { 113 repostCount-- 114 } else if (!wasReposted && isReposted) { 115 repostCount++ 116 } 117 repostCount = Math.max(0, repostCount) 118 } 119 120 let replyCount = post.replyCount ?? 0 121 if ('optimisticReplyCount' in shadow) { 122 replyCount = shadow.optimisticReplyCount ?? replyCount 123 } 124 125 let embed: typeof post.embed 126 if ('embed' in shadow) { 127 if ( 128 (AppBskyEmbedRecord.isView(post.embed) && 129 AppBskyEmbedRecord.isView(shadow.embed)) || 130 (AppBskyEmbedRecordWithMedia.isView(post.embed) && 131 AppBskyEmbedRecordWithMedia.isView(shadow.embed)) 132 ) { 133 embed = shadow.embed 134 } 135 } 136 137 return castAsShadow({ 138 ...post, 139 embed: embed || post.embed, 140 likeCount: likeCount, 141 repostCount: repostCount, 142 replyCount: replyCount, 143 bookmarkCount: bookmarkCount, 144 viewer: { 145 ...(post.viewer || {}), 146 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, 147 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, 148 pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, 149 bookmarked: 150 'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked, 151 }, 152 }) 153} 154 155export function updatePostShadow( 156 queryClient: QueryClient, 157 uri: string, 158 value: Partial<PostShadow>, 159) { 160 const cachedPosts = findPostsInCache(queryClient, uri) 161 for (let post of cachedPosts) { 162 shadows.set(post, {...shadows.get(post), ...value}) 163 } 164 batchedUpdates(() => { 165 emitter.emit(uri) 166 }) 167} 168 169function* findPostsInCache( 170 queryClient: QueryClient, 171 uri: string, 172): Generator<AppBskyFeedDefs.PostView, void> { 173 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { 174 yield post 175 } 176 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { 177 yield post 178 } 179 for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) { 180 yield post 181 } 182 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 183 yield post 184 } 185 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 186 yield post 187 } 188 for (let post of findAllPostsInExploreFeedPreviewsQueryData( 189 queryClient, 190 uri, 191 )) { 192 yield post 193 } 194 for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) { 195 yield post 196 } 197}