mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {
2 AppBskyActorDefs,
3 AppBskyEmbedRecord,
4 AppBskyFeedDefs,
5 AppBskyFeedGetPostThread,
6 AppBskyFeedPost,
7 AtUri,
8 ModerationDecision,
9 ModerationOpts,
10} from '@atproto/api'
11import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
12
13import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
14import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
15import {useAgent} from '#/state/session'
16import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from 'state/queries/post-quotes'
17import {
18 findAllPostsInQueryData as findAllPostsInSearchQueryData,
19 findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
20} from 'state/queries/search-posts'
21import {
22 findAllPostsInQueryData as findAllPostsInNotifsQueryData,
23 findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
24} from './notifications/feed'
25import {
26 findAllPostsInQueryData as findAllPostsInFeedQueryData,
27 findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
28} from './post-feed'
29import {
30 didOrHandleUriMatches,
31 embedViewRecordToPostView,
32 getEmbeddedPost,
33} from './util'
34
35const REPLY_TREE_DEPTH = 10
36export const RQKEY_ROOT = 'post-thread'
37export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
38type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
39
40export interface ThreadCtx {
41 depth: number
42 isHighlightedPost?: boolean
43 hasMore?: boolean
44 isParentLoading?: boolean
45 isChildLoading?: boolean
46 isSelfThread?: boolean
47 hasMoreSelfThread?: boolean
48}
49
50export type ThreadPost = {
51 type: 'post'
52 _reactKey: string
53 uri: string
54 post: AppBskyFeedDefs.PostView
55 record: AppBskyFeedPost.Record
56 parent?: ThreadNode
57 replies?: ThreadNode[]
58 ctx: ThreadCtx
59}
60
61export type ThreadNotFound = {
62 type: 'not-found'
63 _reactKey: string
64 uri: string
65 ctx: ThreadCtx
66}
67
68export type ThreadBlocked = {
69 type: 'blocked'
70 _reactKey: string
71 uri: string
72 ctx: ThreadCtx
73}
74
75export type ThreadUnknown = {
76 type: 'unknown'
77 uri: string
78}
79
80export type ThreadNode =
81 | ThreadPost
82 | ThreadNotFound
83 | ThreadBlocked
84 | ThreadUnknown
85
86export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision>
87
88export type PostThreadQueryData = {
89 thread: ThreadNode
90 threadgate?: AppBskyFeedDefs.ThreadgateView
91}
92
93export function usePostThreadQuery(uri: string | undefined) {
94 const queryClient = useQueryClient()
95 const agent = useAgent()
96 return useQuery<PostThreadQueryData, Error>({
97 gcTime: 0,
98 queryKey: RQKEY(uri || ''),
99 async queryFn() {
100 const res = await agent.getPostThread({
101 uri: uri!,
102 depth: REPLY_TREE_DEPTH,
103 })
104 if (res.success) {
105 const thread = responseToThreadNodes(res.data.thread)
106 annotateSelfThread(thread)
107 return {
108 thread,
109 threadgate: res.data.threadgate as
110 | AppBskyFeedDefs.ThreadgateView
111 | undefined,
112 }
113 }
114 return {thread: {type: 'unknown', uri: uri!}}
115 },
116 enabled: !!uri,
117 placeholderData: () => {
118 if (!uri) return
119 const post = findPostInQueryData(queryClient, uri)
120 if (post) {
121 return {thread: post}
122 }
123 return undefined
124 },
125 })
126}
127
128export function fillThreadModerationCache(
129 cache: ThreadModerationCache,
130 node: ThreadNode,
131 moderationOpts: ModerationOpts,
132) {
133 if (node.type === 'post') {
134 cache.set(node, moderatePost(node.post, moderationOpts))
135 if (node.parent) {
136 fillThreadModerationCache(cache, node.parent, moderationOpts)
137 }
138 if (node.replies) {
139 for (const reply of node.replies) {
140 fillThreadModerationCache(cache, reply, moderationOpts)
141 }
142 }
143 }
144}
145
146export function sortThread(
147 node: ThreadNode,
148 opts: UsePreferencesQueryResponse['threadViewPrefs'],
149 modCache: ThreadModerationCache,
150 currentDid: string | undefined,
151 justPostedUris: Set<string>,
152 threadgateRecordHiddenReplies: Set<string>,
153): ThreadNode {
154 if (node.type !== 'post') {
155 return node
156 }
157 if (node.replies) {
158 node.replies.sort((a: ThreadNode, b: ThreadNode) => {
159 if (a.type !== 'post') {
160 return 1
161 }
162 if (b.type !== 'post') {
163 return -1
164 }
165
166 if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
167 const aIsJustPosted =
168 a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
169 const bIsJustPosted =
170 b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
171 if (aIsJustPosted && bIsJustPosted) {
172 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
173 } else if (aIsJustPosted) {
174 return -1 // reply while onscreen
175 } else if (bIsJustPosted) {
176 return 1 // reply while onscreen
177 }
178 }
179
180 const aIsByOp = a.post.author.did === node.post?.author.did
181 const bIsByOp = b.post.author.did === node.post?.author.did
182 if (aIsByOp && bIsByOp) {
183 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
184 } else if (aIsByOp) {
185 return -1 // op's own reply
186 } else if (bIsByOp) {
187 return 1 // op's own reply
188 }
189
190 const aIsBySelf = a.post.author.did === currentDid
191 const bIsBySelf = b.post.author.did === currentDid
192 if (aIsBySelf && bIsBySelf) {
193 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
194 } else if (aIsBySelf) {
195 return -1 // current account's reply
196 } else if (bIsBySelf) {
197 return 1 // current account's reply
198 }
199
200 const aHidden = threadgateRecordHiddenReplies.has(a.uri)
201 const bHidden = threadgateRecordHiddenReplies.has(b.uri)
202 if (aHidden && !aIsBySelf && !bHidden) {
203 return 1
204 } else if (bHidden && !bIsBySelf && !aHidden) {
205 return -1
206 }
207
208 const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
209 const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
210 if (aBlur !== bBlur) {
211 if (aBlur) {
212 return 1
213 }
214 if (bBlur) {
215 return -1
216 }
217 }
218
219 if (opts.prioritizeFollowedUsers) {
220 const af = a.post.author.viewer?.following
221 const bf = b.post.author.viewer?.following
222 if (af && !bf) {
223 return -1
224 } else if (!af && bf) {
225 return 1
226 }
227 }
228
229 if (opts.sort === 'oldest') {
230 return a.post.indexedAt.localeCompare(b.post.indexedAt)
231 } else if (opts.sort === 'newest') {
232 return b.post.indexedAt.localeCompare(a.post.indexedAt)
233 } else if (opts.sort === 'most-likes') {
234 if (a.post.likeCount === b.post.likeCount) {
235 return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
236 } else {
237 return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
238 }
239 } else if (opts.sort === 'random') {
240 return 0.5 - Math.random() // this is vaguely criminal but we can get away with it
241 }
242 return b.post.indexedAt.localeCompare(a.post.indexedAt)
243 })
244 node.replies.forEach(reply =>
245 sortThread(
246 reply,
247 opts,
248 modCache,
249 currentDid,
250 justPostedUris,
251 threadgateRecordHiddenReplies,
252 ),
253 )
254 }
255 return node
256}
257
258// internal methods
259// =
260
261function responseToThreadNodes(
262 node: ThreadViewNode,
263 depth = 0,
264 direction: 'up' | 'down' | 'start' = 'start',
265): ThreadNode {
266 if (
267 AppBskyFeedDefs.isThreadViewPost(node) &&
268 AppBskyFeedPost.isRecord(node.post.record) &&
269 AppBskyFeedPost.validateRecord(node.post.record).success
270 ) {
271 const post = node.post
272 // These should normally be present. They're missing only for
273 // posts that were *just* created. Ideally, the backend would
274 // know to return zeros. Fill them in manually to compensate.
275 post.replyCount ??= 0
276 post.likeCount ??= 0
277 post.repostCount ??= 0
278 return {
279 type: 'post',
280 _reactKey: node.post.uri,
281 uri: node.post.uri,
282 post: post,
283 record: node.post.record,
284 parent:
285 node.parent && direction !== 'down'
286 ? responseToThreadNodes(node.parent, depth - 1, 'up')
287 : undefined,
288 replies:
289 node.replies?.length && direction !== 'up'
290 ? node.replies
291 .map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
292 // do not show blocked posts in replies
293 .filter(node => node.type !== 'blocked')
294 : undefined,
295 ctx: {
296 depth,
297 isHighlightedPost: depth === 0,
298 hasMore:
299 direction === 'down' && !node.replies?.length && !!node.replyCount,
300 isSelfThread: false, // populated `annotateSelfThread`
301 hasMoreSelfThread: false, // populated in `annotateSelfThread`
302 },
303 }
304 } else if (AppBskyFeedDefs.isBlockedPost(node)) {
305 return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
306 } else if (AppBskyFeedDefs.isNotFoundPost(node)) {
307 return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
308 } else {
309 return {type: 'unknown', uri: ''}
310 }
311}
312
313function annotateSelfThread(thread: ThreadNode) {
314 if (thread.type !== 'post') {
315 return
316 }
317 const selfThreadNodes: ThreadPost[] = [thread]
318
319 let parent: ThreadNode | undefined = thread.parent
320 while (parent) {
321 if (
322 parent.type !== 'post' ||
323 parent.post.author.did !== thread.post.author.did
324 ) {
325 // not a self-thread
326 return
327 }
328 selfThreadNodes.unshift(parent)
329 parent = parent.parent
330 }
331
332 let node = thread
333 for (let i = 0; i < 10; i++) {
334 const reply = node.replies?.find(
335 r => r.type === 'post' && r.post.author.did === thread.post.author.did,
336 )
337 if (reply?.type !== 'post') {
338 break
339 }
340 selfThreadNodes.push(reply)
341 node = reply
342 }
343
344 if (selfThreadNodes.length > 1) {
345 for (const selfThreadNode of selfThreadNodes) {
346 selfThreadNode.ctx.isSelfThread = true
347 }
348 const last = selfThreadNodes[selfThreadNodes.length - 1]
349 if (
350 last &&
351 last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
352 last.post.replyCount && // has replies
353 !last.replies?.length // replies were not hydrated
354 ) {
355 last.ctx.hasMoreSelfThread = true
356 }
357 }
358}
359
360function findPostInQueryData(
361 queryClient: QueryClient,
362 uri: string,
363): ThreadNode | void {
364 let partial
365 for (let item of findAllPostsInQueryData(queryClient, uri)) {
366 if (item.type === 'post') {
367 // Currently, the backend doesn't send full post info in some cases
368 // (for example, for quoted posts). We use missing `likeCount`
369 // as a way to detect that. In the future, we should fix this on
370 // the backend, which will let us always stop on the first result.
371 const hasAllInfo = item.post.likeCount != null
372 if (hasAllInfo) {
373 return item
374 } else {
375 partial = item
376 // Keep searching, we might still find a full post in the cache.
377 }
378 }
379 }
380 return partial
381}
382
383export function* findAllPostsInQueryData(
384 queryClient: QueryClient,
385 uri: string,
386): Generator<ThreadNode, void> {
387 const atUri = new AtUri(uri)
388
389 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
390 queryKey: [RQKEY_ROOT],
391 })
392 for (const [_queryKey, queryData] of queryDatas) {
393 if (!queryData) {
394 continue
395 }
396 const {thread} = queryData
397 for (const item of traverseThread(thread)) {
398 if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
399 const placeholder = threadNodeToPlaceholderThread(item)
400 if (placeholder) {
401 yield placeholder
402 }
403 }
404 const quotedPost =
405 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
406 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
407 yield embedViewRecordToPlaceholderThread(quotedPost)
408 }
409 }
410 }
411 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
412 yield postViewToPlaceholderThread(post)
413 }
414 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
415 yield postViewToPlaceholderThread(post)
416 }
417 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
418 yield postViewToPlaceholderThread(post)
419 }
420 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
421 yield postViewToPlaceholderThread(post)
422 }
423}
424
425export function* findAllProfilesInQueryData(
426 queryClient: QueryClient,
427 did: string,
428): Generator<AppBskyActorDefs.ProfileView, void> {
429 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
430 queryKey: [RQKEY_ROOT],
431 })
432 for (const [_queryKey, queryData] of queryDatas) {
433 if (!queryData) {
434 continue
435 }
436 const {thread} = queryData
437 for (const item of traverseThread(thread)) {
438 if (item.type === 'post' && item.post.author.did === did) {
439 yield item.post.author
440 }
441 const quotedPost =
442 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
443 if (quotedPost?.author.did === did) {
444 yield quotedPost?.author
445 }
446 }
447 }
448 for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
449 yield profile
450 }
451 for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
452 yield profile
453 }
454 for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
455 yield profile
456 }
457}
458
459function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
460 if (node.type === 'post') {
461 if (node.parent) {
462 yield* traverseThread(node.parent)
463 }
464 yield node
465 if (node.replies?.length) {
466 for (const reply of node.replies) {
467 yield* traverseThread(reply)
468 }
469 }
470 }
471}
472
473function threadNodeToPlaceholderThread(
474 node: ThreadNode,
475): ThreadNode | undefined {
476 if (node.type !== 'post') {
477 return undefined
478 }
479 return {
480 type: node.type,
481 _reactKey: node._reactKey,
482 uri: node.uri,
483 post: node.post,
484 record: node.record,
485 parent: undefined,
486 replies: undefined,
487 ctx: {
488 depth: 0,
489 isHighlightedPost: true,
490 hasMore: false,
491 isParentLoading: !!node.record.reply,
492 isChildLoading: !!node.post.replyCount,
493 },
494 }
495}
496
497function postViewToPlaceholderThread(
498 post: AppBskyFeedDefs.PostView,
499): ThreadNode {
500 return {
501 type: 'post',
502 _reactKey: post.uri,
503 uri: post.uri,
504 post: post,
505 record: post.record as AppBskyFeedPost.Record, // validated in notifs
506 parent: undefined,
507 replies: undefined,
508 ctx: {
509 depth: 0,
510 isHighlightedPost: true,
511 hasMore: false,
512 isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
513 isChildLoading: true, // assume yes (show the spinner) just in case
514 },
515 }
516}
517
518function embedViewRecordToPlaceholderThread(
519 record: AppBskyEmbedRecord.ViewRecord,
520): ThreadNode {
521 return {
522 type: 'post',
523 _reactKey: record.uri,
524 uri: record.uri,
525 post: embedViewRecordToPostView(record),
526 record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
527 parent: undefined,
528 replies: undefined,
529 ctx: {
530 depth: 0,
531 isHighlightedPost: true,
532 hasMore: false,
533 isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply,
534 isChildLoading: true, // not available, so assume yes (to show the spinner)
535 },
536 }
537}