my fork of the bluesky client
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 {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
15import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
16import {
17 findAllPostsInQueryData as findAllPostsInSearchQueryData,
18 findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
19} from '#/state/queries/search-posts'
20import {useAgent} from '#/state/session'
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 fetchedAtCache: Map<string, number>,
154 fetchedAt: number,
155 randomCache: Map<string, number>,
156): ThreadNode {
157 if (node.type !== 'post') {
158 return node
159 }
160 if (node.replies) {
161 node.replies.sort((a: ThreadNode, b: ThreadNode) => {
162 if (a.type !== 'post') {
163 return 1
164 }
165 if (b.type !== 'post') {
166 return -1
167 }
168
169 if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
170 const aIsJustPosted =
171 a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
172 const bIsJustPosted =
173 b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
174 if (aIsJustPosted && bIsJustPosted) {
175 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
176 } else if (aIsJustPosted) {
177 return -1 // reply while onscreen
178 } else if (bIsJustPosted) {
179 return 1 // reply while onscreen
180 }
181 }
182
183 const aIsByOp = a.post.author.did === node.post?.author.did
184 const bIsByOp = b.post.author.did === node.post?.author.did
185 if (aIsByOp && bIsByOp) {
186 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
187 } else if (aIsByOp) {
188 return -1 // op's own reply
189 } else if (bIsByOp) {
190 return 1 // op's own reply
191 }
192
193 const aIsBySelf = a.post.author.did === currentDid
194 const bIsBySelf = b.post.author.did === currentDid
195 if (aIsBySelf && bIsBySelf) {
196 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
197 } else if (aIsBySelf) {
198 return -1 // current account's reply
199 } else if (bIsBySelf) {
200 return 1 // current account's reply
201 }
202
203 const aHidden = threadgateRecordHiddenReplies.has(a.uri)
204 const bHidden = threadgateRecordHiddenReplies.has(b.uri)
205 if (aHidden && !aIsBySelf && !bHidden) {
206 return 1
207 } else if (bHidden && !bIsBySelf && !aHidden) {
208 return -1
209 }
210
211 const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
212 const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
213 if (aBlur !== bBlur) {
214 if (aBlur) {
215 return 1
216 }
217 if (bBlur) {
218 return -1
219 }
220 }
221
222 const aPin = Boolean(a.record.text.trim() === '📌')
223 const bPin = Boolean(b.record.text.trim() === '📌')
224 if (aPin !== bPin) {
225 if (aPin) {
226 return 1
227 }
228 if (bPin) {
229 return -1
230 }
231 }
232
233 if (opts.prioritizeFollowedUsers) {
234 const af = a.post.author.viewer?.following
235 const bf = b.post.author.viewer?.following
236 if (af && !bf) {
237 return -1
238 } else if (!af && bf) {
239 return 1
240 }
241 }
242
243 // Split items from different fetches into separate generations.
244 let aFetchedAt = fetchedAtCache.get(a.uri)
245 if (aFetchedAt === undefined) {
246 fetchedAtCache.set(a.uri, fetchedAt)
247 aFetchedAt = fetchedAt
248 }
249 let bFetchedAt = fetchedAtCache.get(b.uri)
250 if (bFetchedAt === undefined) {
251 fetchedAtCache.set(b.uri, fetchedAt)
252 bFetchedAt = fetchedAt
253 }
254
255 if (aFetchedAt !== bFetchedAt) {
256 return aFetchedAt - bFetchedAt // older fetches first
257 } else if (opts.sort === 'hotness') {
258 const aHotness = getHotness(a.post, aFetchedAt)
259 const bHotness = getHotness(b.post, bFetchedAt /* same as aFetchedAt */)
260 return bHotness - aHotness
261 } else if (opts.sort === 'oldest') {
262 return a.post.indexedAt.localeCompare(b.post.indexedAt)
263 } else if (opts.sort === 'newest') {
264 return b.post.indexedAt.localeCompare(a.post.indexedAt)
265 } else if (opts.sort === 'most-likes') {
266 if (a.post.likeCount === b.post.likeCount) {
267 return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
268 } else {
269 return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
270 }
271 } else if (opts.sort === 'random') {
272 let aRandomScore = randomCache.get(a.uri)
273 if (aRandomScore === undefined) {
274 aRandomScore = Math.random()
275 randomCache.set(a.uri, aRandomScore)
276 }
277 let bRandomScore = randomCache.get(b.uri)
278 if (bRandomScore === undefined) {
279 bRandomScore = Math.random()
280 randomCache.set(b.uri, bRandomScore)
281 }
282 // this is vaguely criminal but we can get away with it
283 return aRandomScore - bRandomScore
284 } else {
285 return b.post.indexedAt.localeCompare(a.post.indexedAt)
286 }
287 })
288 node.replies.forEach(reply =>
289 sortThread(
290 reply,
291 opts,
292 modCache,
293 currentDid,
294 justPostedUris,
295 threadgateRecordHiddenReplies,
296 fetchedAtCache,
297 fetchedAt,
298 randomCache,
299 ),
300 )
301 }
302 return node
303}
304
305// internal methods
306// =
307
308// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html
309// We want to give recent comments a real chance (and not bury them deep below the fold)
310// while also surfacing well-liked comments from the past. In the future, we can explore
311// something more sophisticated, but we don't have much data on the client right now.
312function getHotness(post: AppBskyFeedDefs.PostView, fetchedAt: number) {
313 const hoursAgo = Math.max(
314 0,
315 (new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) /
316 (1000 * 60 * 60),
317 )
318 const likeCount = post.likeCount ?? 0
319 const likeOrder = Math.log(3 + likeCount)
320 const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount))
321 const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent)
322 return likeOrder / timePenalty
323}
324
325function responseToThreadNodes(
326 node: ThreadViewNode,
327 depth = 0,
328 direction: 'up' | 'down' | 'start' = 'start',
329): ThreadNode {
330 if (
331 AppBskyFeedDefs.isThreadViewPost(node) &&
332 AppBskyFeedPost.isRecord(node.post.record) &&
333 AppBskyFeedPost.validateRecord(node.post.record).success
334 ) {
335 const post = node.post
336 // These should normally be present. They're missing only for
337 // posts that were *just* created. Ideally, the backend would
338 // know to return zeros. Fill them in manually to compensate.
339 post.replyCount ??= 0
340 post.likeCount ??= 0
341 post.repostCount ??= 0
342 return {
343 type: 'post',
344 _reactKey: node.post.uri,
345 uri: node.post.uri,
346 post: post,
347 record: node.post.record,
348 parent:
349 node.parent && direction !== 'down'
350 ? responseToThreadNodes(node.parent, depth - 1, 'up')
351 : undefined,
352 replies:
353 node.replies?.length && direction !== 'up'
354 ? node.replies
355 .map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
356 // do not show blocked posts in replies
357 .filter(node => node.type !== 'blocked')
358 : undefined,
359 ctx: {
360 depth,
361 isHighlightedPost: depth === 0,
362 hasMore:
363 direction === 'down' && !node.replies?.length && !!node.replyCount,
364 isSelfThread: false, // populated `annotateSelfThread`
365 hasMoreSelfThread: false, // populated in `annotateSelfThread`
366 },
367 }
368 } else if (AppBskyFeedDefs.isBlockedPost(node)) {
369 return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
370 } else if (AppBskyFeedDefs.isNotFoundPost(node)) {
371 return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
372 } else {
373 return {type: 'unknown', uri: ''}
374 }
375}
376
377function annotateSelfThread(thread: ThreadNode) {
378 if (thread.type !== 'post') {
379 return
380 }
381 const selfThreadNodes: ThreadPost[] = [thread]
382
383 let parent: ThreadNode | undefined = thread.parent
384 while (parent) {
385 if (
386 parent.type !== 'post' ||
387 parent.post.author.did !== thread.post.author.did
388 ) {
389 // not a self-thread
390 return
391 }
392 selfThreadNodes.unshift(parent)
393 parent = parent.parent
394 }
395
396 let node = thread
397 for (let i = 0; i < 10; i++) {
398 const reply = node.replies?.find(
399 r => r.type === 'post' && r.post.author.did === thread.post.author.did,
400 )
401 if (reply?.type !== 'post') {
402 break
403 }
404 selfThreadNodes.push(reply)
405 node = reply
406 }
407
408 if (selfThreadNodes.length > 1) {
409 for (const selfThreadNode of selfThreadNodes) {
410 selfThreadNode.ctx.isSelfThread = true
411 }
412 const last = selfThreadNodes[selfThreadNodes.length - 1]
413 if (
414 last &&
415 last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
416 last.post.replyCount && // has replies
417 !last.replies?.length // replies were not hydrated
418 ) {
419 last.ctx.hasMoreSelfThread = true
420 }
421 }
422}
423
424function findPostInQueryData(
425 queryClient: QueryClient,
426 uri: string,
427): ThreadNode | void {
428 let partial
429 for (let item of findAllPostsInQueryData(queryClient, uri)) {
430 if (item.type === 'post') {
431 // Currently, the backend doesn't send full post info in some cases
432 // (for example, for quoted posts). We use missing `likeCount`
433 // as a way to detect that. In the future, we should fix this on
434 // the backend, which will let us always stop on the first result.
435 const hasAllInfo = item.post.likeCount != null
436 if (hasAllInfo) {
437 return item
438 } else {
439 partial = item
440 // Keep searching, we might still find a full post in the cache.
441 }
442 }
443 }
444 return partial
445}
446
447export function* findAllPostsInQueryData(
448 queryClient: QueryClient,
449 uri: string,
450): Generator<ThreadNode, void> {
451 const atUri = new AtUri(uri)
452
453 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
454 queryKey: [RQKEY_ROOT],
455 })
456 for (const [_queryKey, queryData] of queryDatas) {
457 if (!queryData) {
458 continue
459 }
460 const {thread} = queryData
461 for (const item of traverseThread(thread)) {
462 if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
463 const placeholder = threadNodeToPlaceholderThread(item)
464 if (placeholder) {
465 yield placeholder
466 }
467 }
468 const quotedPost =
469 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
470 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
471 yield embedViewRecordToPlaceholderThread(quotedPost)
472 }
473 }
474 }
475 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
476 // Check notifications first. If you have a post in notifications,
477 // it's often due to a like or a repost, and we want to prioritize
478 // a post object with >0 likes/reposts over a stale version with no
479 // metrics in order to avoid a notification->post scroll jump.
480 yield postViewToPlaceholderThread(post)
481 }
482 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
483 yield postViewToPlaceholderThread(post)
484 }
485 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
486 yield postViewToPlaceholderThread(post)
487 }
488 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
489 yield postViewToPlaceholderThread(post)
490 }
491}
492
493export function* findAllProfilesInQueryData(
494 queryClient: QueryClient,
495 did: string,
496): Generator<AppBskyActorDefs.ProfileView, void> {
497 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
498 queryKey: [RQKEY_ROOT],
499 })
500 for (const [_queryKey, queryData] of queryDatas) {
501 if (!queryData) {
502 continue
503 }
504 const {thread} = queryData
505 for (const item of traverseThread(thread)) {
506 if (item.type === 'post' && item.post.author.did === did) {
507 yield item.post.author
508 }
509 const quotedPost =
510 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
511 if (quotedPost?.author.did === did) {
512 yield quotedPost?.author
513 }
514 }
515 }
516 for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
517 yield profile
518 }
519 for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
520 yield profile
521 }
522 for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
523 yield profile
524 }
525}
526
527function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
528 if (node.type === 'post') {
529 if (node.parent) {
530 yield* traverseThread(node.parent)
531 }
532 yield node
533 if (node.replies?.length) {
534 for (const reply of node.replies) {
535 yield* traverseThread(reply)
536 }
537 }
538 }
539}
540
541function threadNodeToPlaceholderThread(
542 node: ThreadNode,
543): ThreadNode | undefined {
544 if (node.type !== 'post') {
545 return undefined
546 }
547 return {
548 type: node.type,
549 _reactKey: node._reactKey,
550 uri: node.uri,
551 post: node.post,
552 record: node.record,
553 parent: undefined,
554 replies: undefined,
555 ctx: {
556 depth: 0,
557 isHighlightedPost: true,
558 hasMore: false,
559 isParentLoading: !!node.record.reply,
560 isChildLoading: !!node.post.replyCount,
561 },
562 }
563}
564
565function postViewToPlaceholderThread(
566 post: AppBskyFeedDefs.PostView,
567): ThreadNode {
568 return {
569 type: 'post',
570 _reactKey: post.uri,
571 uri: post.uri,
572 post: post,
573 record: post.record as AppBskyFeedPost.Record, // validated in notifs
574 parent: undefined,
575 replies: undefined,
576 ctx: {
577 depth: 0,
578 isHighlightedPost: true,
579 hasMore: false,
580 isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
581 isChildLoading: true, // assume yes (show the spinner) just in case
582 },
583 }
584}
585
586function embedViewRecordToPlaceholderThread(
587 record: AppBskyEmbedRecord.ViewRecord,
588): ThreadNode {
589 return {
590 type: 'post',
591 _reactKey: record.uri,
592 uri: record.uri,
593 post: embedViewRecordToPostView(record),
594 record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
595 parent: undefined,
596 replies: undefined,
597 ctx: {
598 depth: 0,
599 isHighlightedPost: true,
600 hasMore: false,
601 isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply,
602 isChildLoading: true, // not available, so assume yes (to show the spinner)
603 },
604 }
605}