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