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