forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback} from 'react'
2import {
3 type $Typed,
4 type AppBskyActorDefs,
5 type AppBskyFeedDefs,
6 AppBskyUnspeccedDefs,
7 type AppBskyUnspeccedGetPostThreadOtherV2,
8 type AppBskyUnspeccedGetPostThreadV2,
9 AtUri,
10} from '@atproto/api'
11import {type QueryClient, useQueryClient} from '@tanstack/react-query'
12
13import {
14 dangerousGetPostShadow,
15 updatePostShadow,
16} from '#/state/cache/post-shadow'
17import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery'
18import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
19import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
20import {findAllPostsInQueryData as findAllPostsInPostQueryData} from '#/state/queries/post'
21import {findAllPostsInQueryData as findAllPostsInAlsoLikedQueryData} from '#/state/queries/post-also-liked'
22import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
23import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
24import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
25import {usePostThreadContext} from '#/state/queries/usePostThread'
26import {getBranch} from '#/state/queries/usePostThread/traversal'
27import {
28 type ApiThreadItem,
29 type createPostThreadOtherQueryKey,
30 type createPostThreadQueryKey,
31 type PostThreadParams,
32 postThreadQueryKeyRoot,
33} from '#/state/queries/usePostThread/types'
34import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
35import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
36import {
37 didOrHandleUriMatches,
38 embedViewRecordToPostView,
39 getEmbeddedPost,
40} from '#/state/queries/util'
41
42export function createCacheMutator({
43 queryClient,
44 postThreadQueryKey,
45 postThreadOtherQueryKey,
46 params,
47}: {
48 queryClient: QueryClient
49 postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
50 postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
51 params: Pick<PostThreadParams, 'view'> & {below: number}
52}) {
53 return {
54 insertReplies(
55 parentUri: string,
56 replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
57 ) {
58 /*
59 * Main thread query mutator.
60 */
61 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
62 postThreadQueryKey,
63 data => {
64 if (!data) return
65 return {
66 ...data,
67 thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
68 ...data.thread,
69 ]),
70 }
71 },
72 )
73
74 /*
75 * Additional replies query mutator.
76 */
77 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>(
78 postThreadOtherQueryKey,
79 data => {
80 if (!data) return
81 return {
82 ...data,
83 thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([
84 ...data.thread,
85 ]),
86 }
87 },
88 )
89
90 function mutator<T>(thread: ApiThreadItem[]): T[] {
91 for (let i = 0; i < thread.length; i++) {
92 const parent = thread[i]
93
94 if (!AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) continue
95 if (parent.uri !== parentUri) continue
96
97 /*
98 * Update parent data
99 */
100 const shadow = dangerousGetPostShadow(parent.value.post)
101 const prevOptimisticCount = shadow?.optimisticReplyCount
102 const prevReplyCount = parent.value.post.replyCount
103 // prefer optimistic count, if we already have some
104 const currentReplyCount =
105 (prevOptimisticCount ?? prevReplyCount ?? 0) + 1
106
107 /*
108 * We must update the value in the query cache in order for thread
109 * traversal to properly compute required metadata.
110 */
111 parent.value.post.replyCount = currentReplyCount
112
113 /**
114 * Additionally, we need to update the post shadow to keep track of
115 * these new values, since mutating the post object above does not
116 * cause a re-render.
117 */
118 updatePostShadow(queryClient, parent.value.post.uri, {
119 optimisticReplyCount: currentReplyCount,
120 })
121
122 const opDid = getRootPostAtUri(parent.value.post)?.host
123 const nextPreexistingItem = thread.at(i + 1)
124 const isEndOfReplyChain =
125 !nextPreexistingItem || nextPreexistingItem.depth <= parent.depth
126 const isParentRoot = parent.depth === 0
127 const isParentBelowRoot = parent.depth > 0
128 const optimisticReply = replies.at(0)
129 const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost(
130 optimisticReply?.value,
131 )
132 ? opDid === optimisticReply.value.post.author.did
133 : false
134
135 /*
136 * Always insert replies if the following conditions are met. Max
137 * depth checks are handled below.
138 */
139 const canAlwaysInsertReplies =
140 isParentRoot ||
141 (params.view === 'tree' && isParentBelowRoot) ||
142 (params.view === 'linear' && isEndOfReplyChain)
143 /*
144 * Maybe insert replies if we're in linear view, the replier is the
145 * OP, and certain conditions are met
146 */
147 const shouldReplaceWithOPReplies =
148 params.view === 'linear' && opIsReplier && isParentBelowRoot
149
150 if (canAlwaysInsertReplies || shouldReplaceWithOPReplies) {
151 const branch = getBranch(thread, i, parent.depth)
152 /*
153 * OP insertions replace other replies _in linear view_.
154 */
155 const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
156 const itemsToInsert = replies
157 .map((r, ri) => {
158 r.depth = parent.depth + 1 + ri
159 return r
160 })
161 .filter(r => {
162 // Filter out replies that are too deep for our UI
163 return r.depth <= params.below
164 })
165
166 thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
167 }
168 }
169
170 return thread as T[]
171 }
172 },
173 /**
174 * Unused atm, post shadow does the trick, but it would be nice to clean up
175 * the whole sub-tree on deletes.
176 */
177 deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
178 queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
179 postThreadQueryKey,
180 queryData => {
181 if (!queryData) return
182
183 const thread = [...queryData.thread]
184
185 for (let i = 0; i < thread.length; i++) {
186 const existingPost = thread[i]
187 if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
188
189 if (existingPost.uri === post.uri) {
190 const branch = getBranch(thread, i, existingPost.depth)
191 thread.splice(branch.start, branch.length)
192 break
193 }
194 }
195
196 return {
197 ...queryData,
198 thread,
199 }
200 },
201 )
202 },
203 }
204}
205
206export function getThreadPlaceholder(
207 queryClient: QueryClient,
208 uri: string,
209): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void {
210 let partial
211 for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
212 /*
213 * Currently, the backend doesn't send full post info in some cases (for
214 * example, for quoted posts). We use missing `likeCount` as a way to
215 * detect that. In the future, we should fix this on the backend, which
216 * will let us always stop on the first result.
217 *
218 * TODO can we send in feeds and quotes?
219 */
220 const hasAllInfo = item.value.post.likeCount != null
221 if (hasAllInfo) {
222 return item
223 } else {
224 // Keep searching, we might still find a full post in the cache.
225 partial = item
226 }
227 }
228 return partial
229}
230
231export function* getThreadPlaceholderCandidates(
232 queryClient: QueryClient,
233 uri: string,
234): Generator<
235 $Typed<
236 Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
237 value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
238 }
239 >,
240 void
241> {
242 /*
243 * Check post thread queries first
244 */
245 for (const post of findAllPostsInQueryData(queryClient, uri)) {
246 yield postViewToThreadPlaceholder(post)
247 }
248
249 /*
250 * Check notifications first. If you have a post in notifications, it's
251 * often due to a like or a repost, and we want to prioritize a post object
252 * with >0 likes/reposts over a stale version with no metrics in order to
253 * avoid a notification->post scroll jump.
254 */
255 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
256 yield postViewToThreadPlaceholder(post)
257 }
258 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
259 yield postViewToThreadPlaceholder(post)
260 }
261 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
262 yield postViewToThreadPlaceholder(post)
263 }
264 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
265 yield postViewToThreadPlaceholder(post)
266 }
267 for (let post of findAllPostsInAlsoLikedQueryData(queryClient, uri)) {
268 yield postViewToThreadPlaceholder(post)
269 }
270 for (let post of findAllPostsInPostQueryData(queryClient, uri)) {
271 yield postViewToThreadPlaceholder(post)
272 }
273 for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) {
274 yield postViewToThreadPlaceholder(post)
275 }
276 for (let post of findAllPostsInExploreFeedPreviewsQueryData(
277 queryClient,
278 uri,
279 )) {
280 yield postViewToThreadPlaceholder(post)
281 }
282}
283
284export function* findAllPostsInQueryData(
285 queryClient: QueryClient,
286 uri: string,
287): Generator<AppBskyFeedDefs.PostView, void> {
288 const atUri = new AtUri(uri)
289 const queryDatas =
290 queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
291 queryKey: [postThreadQueryKeyRoot],
292 })
293
294 for (const [_queryKey, queryData] of queryDatas) {
295 if (!queryData) continue
296
297 const {thread} = queryData
298
299 for (const item of thread) {
300 if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
301 if (didOrHandleUriMatches(atUri, item.value.post)) {
302 yield item.value.post
303 }
304
305 const qp = getEmbeddedPost(item.value.post.embed)
306 if (qp && didOrHandleUriMatches(atUri, qp)) {
307 yield embedViewRecordToPostView(qp)
308 }
309 }
310 }
311 }
312}
313
314export function* findAllProfilesInQueryData(
315 queryClient: QueryClient,
316 did: string,
317): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
318 const queryDatas =
319 queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
320 queryKey: [postThreadQueryKeyRoot],
321 })
322
323 for (const [_queryKey, queryData] of queryDatas) {
324 if (!queryData) continue
325
326 const {thread} = queryData
327
328 for (const item of thread) {
329 if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
330 if (item.value.post.author.did === did) {
331 yield item.value.post.author
332 }
333
334 const qp = getEmbeddedPost(item.value.post.embed)
335 if (qp && qp.author.did === did) {
336 yield qp.author
337 }
338 }
339 }
340 }
341}
342
343export function useUpdatePostThreadThreadgateQueryCache() {
344 const qc = useQueryClient()
345 const context = usePostThreadContext()
346
347 return useCallback(
348 (threadgate: AppBskyFeedDefs.ThreadgateView) => {
349 if (!context) return
350
351 function mutator<T>(thread: ApiThreadItem[]): T[] {
352 for (let i = 0; i < thread.length; i++) {
353 const item = thread[i]
354
355 if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) continue
356
357 if (item.depth === 0) {
358 thread.splice(i, 1, {
359 ...item,
360 value: {
361 ...item.value,
362 post: {
363 ...item.value.post,
364 threadgate,
365 },
366 },
367 })
368 }
369 }
370
371 return thread as T[]
372 }
373
374 qc.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
375 context.postThreadQueryKey,
376 data => {
377 if (!data) return
378 return {
379 ...data,
380 thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
381 ...data.thread,
382 ]),
383 }
384 },
385 )
386 },
387 [qc, context],
388 )
389}