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