mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 537 lines 15 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 {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}