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