An ATproto social media client -- with an independent Appview.
at main 631 lines 19 kB view raw
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}