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 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}