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