my fork of the bluesky client
at main 605 lines 18 kB view raw
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}