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