An ATproto social media client -- with an independent Appview.
1import {
2 type AppBskyActorDefs,
3 type AppBskyEmbedRecord,
4 AppBskyFeedDefs,
5 type AppBskyFeedGetPostThread,
6 AppBskyFeedPost,
7 AtUri,
8 moderatePost,
9 type ModerationDecision,
10 type ModerationOpts,
11} from '@atproto/api'
12import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
13
14import {
15 findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
16 findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData,
17} from '#/state/queries/explore-feed-previews'
18import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
19import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
20import {
21 findAllPostsInQueryData as findAllPostsInSearchQueryData,
22 findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
23} from '#/state/queries/search-posts'
24import {useAgent} from '#/state/session'
25import * as bsky from '#/types/bsky'
26import {
27 findAllPostsInQueryData as findAllPostsInNotifsQueryData,
28 findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
29} from './notifications/feed'
30import {
31 findAllPostsInQueryData as findAllPostsInFeedQueryData,
32 findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
33} from './post-feed'
34import {
35 didOrHandleUriMatches,
36 embedViewRecordToPostView,
37 getEmbeddedPost,
38} from './util'
39
40const REPLY_TREE_DEPTH = 10
41export const RQKEY_ROOT = 'post-thread'
42export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
43type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
44
45export interface ThreadCtx {
46 depth: number
47 isHighlightedPost?: boolean
48 hasMore?: boolean
49 isParentLoading?: boolean
50 isChildLoading?: boolean
51 isSelfThread?: boolean
52 hasMoreSelfThread?: boolean
53}
54
55export type ThreadPost = {
56 type: 'post'
57 _reactKey: string
58 uri: string
59 post: AppBskyFeedDefs.PostView
60 record: AppBskyFeedPost.Record
61 parent: ThreadNode | undefined
62 replies: ThreadNode[] | undefined
63 hasOPLike: boolean | undefined
64 ctx: ThreadCtx
65}
66
67export type ThreadNotFound = {
68 type: 'not-found'
69 _reactKey: string
70 uri: string
71 ctx: ThreadCtx
72}
73
74export type ThreadBlocked = {
75 type: 'blocked'
76 _reactKey: string
77 uri: string
78 ctx: ThreadCtx
79}
80
81export type ThreadUnknown = {
82 type: 'unknown'
83 uri: string
84}
85
86export type ThreadNode =
87 | ThreadPost
88 | ThreadNotFound
89 | ThreadBlocked
90 | ThreadUnknown
91
92export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision>
93
94export type PostThreadQueryData = {
95 thread: ThreadNode
96 threadgate?: AppBskyFeedDefs.ThreadgateView
97}
98
99export function usePostThreadQuery(uri: string | undefined) {
100 const queryClient = useQueryClient()
101 const agent = useAgent()
102 return useQuery<PostThreadQueryData, Error>({
103 gcTime: 0,
104 queryKey: RQKEY(uri || ''),
105 async queryFn() {
106 const res = await agent.getPostThread({
107 uri: uri!,
108 depth: REPLY_TREE_DEPTH,
109 })
110 if (res.success) {
111 const thread = responseToThreadNodes(res.data.thread)
112 annotateSelfThread(thread)
113 return {
114 thread,
115 threadgate: res.data.threadgate as
116 | AppBskyFeedDefs.ThreadgateView
117 | undefined,
118 }
119 }
120 return {thread: {type: 'unknown', uri: uri!}}
121 },
122 enabled: !!uri,
123 placeholderData: () => {
124 if (!uri) return
125 const post = findPostInQueryData(queryClient, uri)
126 if (post) {
127 return {thread: post}
128 }
129 return undefined
130 },
131 })
132}
133
134export function fillThreadModerationCache(
135 cache: ThreadModerationCache,
136 node: ThreadNode,
137 moderationOpts: ModerationOpts,
138) {
139 if (node.type === 'post') {
140 cache.set(node, moderatePost(node.post, moderationOpts))
141 if (node.parent) {
142 fillThreadModerationCache(cache, node.parent, moderationOpts)
143 }
144 if (node.replies) {
145 for (const reply of node.replies) {
146 fillThreadModerationCache(cache, reply, moderationOpts)
147 }
148 }
149 }
150}
151
152export function sortThread(
153 node: ThreadNode,
154 opts: UsePreferencesQueryResponse['threadViewPrefs'],
155 modCache: ThreadModerationCache,
156 currentDid: string | undefined,
157 justPostedUris: Set<string>,
158 threadgateRecordHiddenReplies: Set<string>,
159 fetchedAtCache: Map<string, number>,
160 fetchedAt: number,
161 randomCache: Map<string, number>,
162): ThreadNode {
163 if (node.type !== 'post') {
164 return node
165 }
166 if (node.replies) {
167 node.replies.sort((a: ThreadNode, b: ThreadNode) => {
168 if (a.type !== 'post') {
169 return 1
170 }
171 if (b.type !== 'post') {
172 return -1
173 }
174
175 if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
176 const aIsJustPosted =
177 a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
178 const bIsJustPosted =
179 b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
180 if (aIsJustPosted && bIsJustPosted) {
181 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
182 } else if (aIsJustPosted) {
183 return -1 // reply while onscreen
184 } else if (bIsJustPosted) {
185 return 1 // reply while onscreen
186 }
187 }
188
189 const aIsByOp = a.post.author.did === node.post?.author.did
190 const bIsByOp = b.post.author.did === node.post?.author.did
191 if (aIsByOp && bIsByOp) {
192 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
193 } else if (aIsByOp) {
194 return -1 // op's own reply
195 } else if (bIsByOp) {
196 return 1 // op's own reply
197 }
198
199 const aIsBySelf = a.post.author.did === currentDid
200 const bIsBySelf = b.post.author.did === currentDid
201 if (aIsBySelf && bIsBySelf) {
202 return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
203 } else if (aIsBySelf) {
204 return -1 // current account's reply
205 } else if (bIsBySelf) {
206 return 1 // current account's reply
207 }
208
209 const aHidden = threadgateRecordHiddenReplies.has(a.uri)
210 const bHidden = threadgateRecordHiddenReplies.has(b.uri)
211 if (aHidden && !aIsBySelf && !bHidden) {
212 return 1
213 } else if (bHidden && !bIsBySelf && !aHidden) {
214 return -1
215 }
216
217 const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
218 const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
219 if (aBlur !== bBlur) {
220 if (aBlur) {
221 return 1
222 }
223 if (bBlur) {
224 return -1
225 }
226 }
227
228 const aPin = Boolean(a.record.text.trim() === '📌')
229 const bPin = Boolean(b.record.text.trim() === '📌')
230 if (aPin !== bPin) {
231 if (aPin) {
232 return 1
233 }
234 if (bPin) {
235 return -1
236 }
237 }
238
239 if (opts.prioritizeFollowedUsers) {
240 const af = a.post.author.viewer?.following
241 const bf = b.post.author.viewer?.following
242 if (af && !bf) {
243 return -1
244 } else if (!af && bf) {
245 return 1
246 }
247 }
248
249 // Split items from different fetches into separate generations.
250 let aFetchedAt = fetchedAtCache.get(a.uri)
251 if (aFetchedAt === undefined) {
252 fetchedAtCache.set(a.uri, fetchedAt)
253 aFetchedAt = fetchedAt
254 }
255 let bFetchedAt = fetchedAtCache.get(b.uri)
256 if (bFetchedAt === undefined) {
257 fetchedAtCache.set(b.uri, fetchedAt)
258 bFetchedAt = fetchedAt
259 }
260
261 if (aFetchedAt !== bFetchedAt) {
262 return aFetchedAt - bFetchedAt // older fetches first
263 } else if (opts.sort === 'hotness') {
264 const aHotness = getHotness(a, aFetchedAt)
265 const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */)
266 return bHotness - aHotness
267 } else if (opts.sort === 'oldest') {
268 return a.post.indexedAt.localeCompare(b.post.indexedAt)
269 } else if (opts.sort === 'newest') {
270 return b.post.indexedAt.localeCompare(a.post.indexedAt)
271 } else if (opts.sort === 'most-likes') {
272 if (a.post.likeCount === b.post.likeCount) {
273 return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
274 } else {
275 return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
276 }
277 } else if (opts.sort === 'random') {
278 let aRandomScore = randomCache.get(a.uri)
279 if (aRandomScore === undefined) {
280 aRandomScore = Math.random()
281 randomCache.set(a.uri, aRandomScore)
282 }
283 let bRandomScore = randomCache.get(b.uri)
284 if (bRandomScore === undefined) {
285 bRandomScore = Math.random()
286 randomCache.set(b.uri, bRandomScore)
287 }
288 // this is vaguely criminal but we can get away with it
289 return aRandomScore - bRandomScore
290 } else {
291 return b.post.indexedAt.localeCompare(a.post.indexedAt)
292 }
293 })
294 node.replies.forEach(reply =>
295 sortThread(
296 reply,
297 opts,
298 modCache,
299 currentDid,
300 justPostedUris,
301 threadgateRecordHiddenReplies,
302 fetchedAtCache,
303 fetchedAt,
304 randomCache,
305 ),
306 )
307 }
308 return node
309}
310
311// internal methods
312// =
313
314// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html
315// We want to give recent comments a real chance (and not bury them deep below the fold)
316// while also surfacing well-liked comments from the past. In the future, we can explore
317// something more sophisticated, but we don't have much data on the client right now.
318function getHotness(threadPost: ThreadPost, fetchedAt: number) {
319 const {post, hasOPLike} = threadPost
320 const hoursAgo = Math.max(
321 0,
322 (new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) /
323 (1000 * 60 * 60),
324 )
325 const likeCount = post.likeCount ?? 0
326 const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0)
327 const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount))
328 const opLikeBoost = hasOPLike ? 0.8 : 1.0
329 const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost)
330 return likeOrder / timePenalty
331}
332
333function responseToThreadNodes(
334 node: ThreadViewNode,
335 depth = 0,
336 direction: 'up' | 'down' | 'start' = 'start',
337): ThreadNode {
338 if (
339 AppBskyFeedDefs.isThreadViewPost(node) &&
340 bsky.dangerousIsType<AppBskyFeedPost.Record>(
341 node.post.record,
342 AppBskyFeedPost.isRecord,
343 )
344 ) {
345 const post = node.post
346 // These should normally be present. They're missing only for
347 // posts that were *just* created. Ideally, the backend would
348 // know to return zeros. Fill them in manually to compensate.
349 post.replyCount ??= 0
350 post.likeCount ??= 0
351 post.repostCount ??= 0
352 return {
353 type: 'post',
354 _reactKey: node.post.uri,
355 uri: node.post.uri,
356 post: post,
357 record: node.post.record,
358 parent:
359 node.parent && direction !== 'down'
360 ? responseToThreadNodes(node.parent, depth - 1, 'up')
361 : undefined,
362 replies:
363 node.replies?.length && direction !== 'up'
364 ? node.replies
365 .map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
366 // do not show blocked posts in replies
367 .filter(node => node.type !== 'blocked')
368 : undefined,
369 hasOPLike: Boolean(node?.threadContext?.rootAuthorLike),
370 ctx: {
371 depth,
372 isHighlightedPost: depth === 0,
373 hasMore:
374 direction === 'down' && !node.replies?.length && !!post.replyCount,
375 isSelfThread: false, // populated `annotateSelfThread`
376 hasMoreSelfThread: false, // populated in `annotateSelfThread`
377 },
378 }
379 } else if (AppBskyFeedDefs.isBlockedPost(node)) {
380 return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
381 } else if (AppBskyFeedDefs.isNotFoundPost(node)) {
382 return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
383 } else {
384 return {type: 'unknown', uri: ''}
385 }
386}
387
388function annotateSelfThread(thread: ThreadNode) {
389 if (thread.type !== 'post') {
390 return
391 }
392 const selfThreadNodes: ThreadPost[] = [thread]
393
394 let parent: ThreadNode | undefined = thread.parent
395 while (parent) {
396 if (
397 parent.type !== 'post' ||
398 parent.post.author.did !== thread.post.author.did
399 ) {
400 // not a self-thread
401 return
402 }
403 selfThreadNodes.unshift(parent)
404 parent = parent.parent
405 }
406
407 let node = thread
408 for (let i = 0; i < 10; i++) {
409 const reply = node.replies?.find(
410 r => r.type === 'post' && r.post.author.did === thread.post.author.did,
411 )
412 if (reply?.type !== 'post') {
413 break
414 }
415 selfThreadNodes.push(reply)
416 node = reply
417 }
418
419 if (selfThreadNodes.length > 1) {
420 for (const selfThreadNode of selfThreadNodes) {
421 selfThreadNode.ctx.isSelfThread = true
422 }
423 const last = selfThreadNodes[selfThreadNodes.length - 1]
424 if (
425 last &&
426 last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
427 last.post.replyCount && // has replies
428 !last.replies?.length // replies were not hydrated
429 ) {
430 last.ctx.hasMoreSelfThread = true
431 }
432 }
433}
434
435function findPostInQueryData(
436 queryClient: QueryClient,
437 uri: string,
438): ThreadNode | void {
439 let partial
440 for (let item of findAllPostsInQueryData(queryClient, uri)) {
441 if (item.type === 'post') {
442 // Currently, the backend doesn't send full post info in some cases
443 // (for example, for quoted posts). We use missing `likeCount`
444 // as a way to detect that. In the future, we should fix this on
445 // the backend, which will let us always stop on the first result.
446 const hasAllInfo = item.post.likeCount != null
447 if (hasAllInfo) {
448 return item
449 } else {
450 partial = item
451 // Keep searching, we might still find a full post in the cache.
452 }
453 }
454 }
455 return partial
456}
457
458export function* findAllPostsInQueryData(
459 queryClient: QueryClient,
460 uri: string,
461): Generator<ThreadNode, void> {
462 const atUri = new AtUri(uri)
463
464 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
465 queryKey: [RQKEY_ROOT],
466 })
467 for (const [_queryKey, queryData] of queryDatas) {
468 if (!queryData) {
469 continue
470 }
471 const {thread} = queryData
472 for (const item of traverseThread(thread)) {
473 if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
474 const placeholder = threadNodeToPlaceholderThread(item)
475 if (placeholder) {
476 yield placeholder
477 }
478 }
479 const quotedPost =
480 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
481 if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
482 yield embedViewRecordToPlaceholderThread(quotedPost)
483 }
484 }
485 }
486 for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
487 // Check notifications first. If you have a post in notifications,
488 // it's often due to a like or a repost, and we want to prioritize
489 // a post object with >0 likes/reposts over a stale version with no
490 // metrics in order to avoid a notification->post scroll jump.
491 yield postViewToPlaceholderThread(post)
492 }
493 for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
494 yield postViewToPlaceholderThread(post)
495 }
496 for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
497 yield postViewToPlaceholderThread(post)
498 }
499 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
500 yield postViewToPlaceholderThread(post)
501 }
502 for (let post of findAllPostsInExploreFeedPreviewsQueryData(
503 queryClient,
504 uri,
505 )) {
506 yield postViewToPlaceholderThread(post)
507 }
508}
509
510export function* findAllProfilesInQueryData(
511 queryClient: QueryClient,
512 did: string,
513): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
514 const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
515 queryKey: [RQKEY_ROOT],
516 })
517 for (const [_queryKey, queryData] of queryDatas) {
518 if (!queryData) {
519 continue
520 }
521 const {thread} = queryData
522 for (const item of traverseThread(thread)) {
523 if (item.type === 'post' && item.post.author.did === did) {
524 yield item.post.author
525 }
526 const quotedPost =
527 item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
528 if (quotedPost?.author.did === did) {
529 yield quotedPost?.author
530 }
531 }
532 }
533 for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
534 yield profile
535 }
536 for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
537 yield profile
538 }
539 for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
540 yield profile
541 }
542 for (let profile of findAllProfilesInExploreFeedPreviewsQueryData(
543 queryClient,
544 did,
545 )) {
546 yield profile
547 }
548}
549
550function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
551 if (node.type === 'post') {
552 if (node.parent) {
553 yield* traverseThread(node.parent)
554 }
555 yield node
556 if (node.replies?.length) {
557 for (const reply of node.replies) {
558 yield* traverseThread(reply)
559 }
560 }
561 }
562}
563
564function threadNodeToPlaceholderThread(
565 node: ThreadNode,
566): ThreadNode | undefined {
567 if (node.type !== 'post') {
568 return undefined
569 }
570 return {
571 type: node.type,
572 _reactKey: node._reactKey,
573 uri: node.uri,
574 post: node.post,
575 record: node.record,
576 parent: undefined,
577 replies: undefined,
578 hasOPLike: undefined,
579 ctx: {
580 depth: 0,
581 isHighlightedPost: true,
582 hasMore: false,
583 isParentLoading: !!node.record.reply,
584 isChildLoading: !!node.post.replyCount,
585 },
586 }
587}
588
589function postViewToPlaceholderThread(
590 post: AppBskyFeedDefs.PostView,
591): ThreadNode {
592 return {
593 type: 'post',
594 _reactKey: post.uri,
595 uri: post.uri,
596 post: post,
597 record: post.record as AppBskyFeedPost.Record, // validated in notifs
598 parent: undefined,
599 replies: undefined,
600 hasOPLike: undefined,
601 ctx: {
602 depth: 0,
603 isHighlightedPost: true,
604 hasMore: false,
605 isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
606 isChildLoading: true, // assume yes (show the spinner) just in case
607 },
608 }
609}
610
611function embedViewRecordToPlaceholderThread(
612 record: AppBskyEmbedRecord.ViewRecord,
613): ThreadNode {
614 return {
615 type: 'post',
616 _reactKey: record.uri,
617 uri: record.uri,
618 post: embedViewRecordToPostView(record),
619 record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
620 parent: undefined,
621 replies: undefined,
622 hasOPLike: undefined,
623 ctx: {
624 depth: 0,
625 isHighlightedPost: true,
626 hasMore: false,
627 isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply,
628 isChildLoading: true, // not available, so assume yes (to show the spinner)
629 },
630 }
631}