import { type AppBskyActorDefs, type AppBskyEmbedRecord, AppBskyFeedDefs, type AppBskyFeedGetPostThread, AppBskyFeedPost, AtUri, moderatePost, type ModerationDecision, type ModerationOpts, } from '@atproto/api' import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import { findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData, findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData, } from '#/state/queries/explore-feed-previews' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes' import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import { findAllPostsInQueryData as findAllPostsInSearchQueryData, findAllProfilesInQueryData as findAllProfilesInSearchQueryData, } from '#/state/queries/search-posts' import {useAgent} from '#/state/session' import * as bsky from '#/types/bsky' import { findAllPostsInQueryData as findAllPostsInNotifsQueryData, findAllProfilesInQueryData as findAllProfilesInNotifsQueryData, } from './notifications/feed' import { findAllPostsInQueryData as findAllPostsInFeedQueryData, findAllProfilesInQueryData as findAllProfilesInFeedQueryData, } from './post-feed' import { didOrHandleUriMatches, embedViewRecordToPostView, getEmbeddedPost, } from './util' const REPLY_TREE_DEPTH = 10 export const RQKEY_ROOT = 'post-thread' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] export interface ThreadCtx { depth: number isHighlightedPost?: boolean hasMore?: boolean isParentLoading?: boolean isChildLoading?: boolean isSelfThread?: boolean hasMoreSelfThread?: boolean } export type ThreadPost = { type: 'post' _reactKey: string uri: string post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record parent: ThreadNode | undefined replies: ThreadNode[] | undefined hasOPLike: boolean | undefined ctx: ThreadCtx } export type ThreadNotFound = { type: 'not-found' _reactKey: string uri: string ctx: ThreadCtx } export type ThreadBlocked = { type: 'blocked' _reactKey: string uri: string ctx: ThreadCtx } export type ThreadUnknown = { type: 'unknown' uri: string } export type ThreadNode = | ThreadPost | ThreadNotFound | ThreadBlocked | ThreadUnknown export type ThreadModerationCache = WeakMap export type PostThreadQueryData = { thread: ThreadNode threadgate?: AppBskyFeedDefs.ThreadgateView } export function usePostThreadQuery(uri: string | undefined) { const queryClient = useQueryClient() const agent = useAgent() return useQuery({ gcTime: 0, queryKey: RQKEY(uri || ''), async queryFn() { const res = await agent.getPostThread({ uri: uri!, depth: REPLY_TREE_DEPTH, }) if (res.success) { const thread = responseToThreadNodes(res.data.thread) annotateSelfThread(thread) return { thread, threadgate: res.data.threadgate as | AppBskyFeedDefs.ThreadgateView | undefined, } } return {thread: {type: 'unknown', uri: uri!}} }, enabled: !!uri, placeholderData: () => { if (!uri) return const post = findPostInQueryData(queryClient, uri) if (post) { return {thread: post} } return undefined }, }) } export function fillThreadModerationCache( cache: ThreadModerationCache, node: ThreadNode, moderationOpts: ModerationOpts, ) { if (node.type === 'post') { cache.set(node, moderatePost(node.post, moderationOpts)) if (node.parent) { fillThreadModerationCache(cache, node.parent, moderationOpts) } if (node.replies) { for (const reply of node.replies) { fillThreadModerationCache(cache, reply, moderationOpts) } } } } export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], modCache: ThreadModerationCache, currentDid: string | undefined, justPostedUris: Set, threadgateRecordHiddenReplies: Set, fetchedAtCache: Map, fetchedAt: number, randomCache: Map, ): ThreadNode { if (node.type !== 'post') { return node } if (node.replies) { node.replies.sort((a: ThreadNode, b: ThreadNode) => { if (a.type !== 'post') { return 1 } if (b.type !== 'post') { return -1 } if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) { const aIsJustPosted = a.post.author.did === currentDid && justPostedUris.has(a.post.uri) const bIsJustPosted = b.post.author.did === currentDid && justPostedUris.has(b.post.uri) if (aIsJustPosted && bIsJustPosted) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsJustPosted) { return -1 // reply while onscreen } else if (bIsJustPosted) { return 1 // reply while onscreen } } const aIsByOp = a.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did if (aIsByOp && bIsByOp) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsByOp) { return -1 // op's own reply } else if (bIsByOp) { return 1 // op's own reply } const aIsBySelf = a.post.author.did === currentDid const bIsBySelf = b.post.author.did === currentDid if (aIsBySelf && bIsBySelf) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsBySelf) { return -1 // current account's reply } else if (bIsBySelf) { return 1 // current account's reply } const aHidden = threadgateRecordHiddenReplies.has(a.uri) const bHidden = threadgateRecordHiddenReplies.has(b.uri) if (aHidden && !aIsBySelf && !bHidden) { return 1 } else if (bHidden && !bIsBySelf && !aHidden) { return -1 } const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) if (aBlur !== bBlur) { if (aBlur) { return 1 } if (bBlur) { return -1 } } const aPin = Boolean(a.record.text.trim() === '📌') const bPin = Boolean(b.record.text.trim() === '📌') if (aPin !== bPin) { if (aPin) { return 1 } if (bPin) { return -1 } } if (opts.prioritizeFollowedUsers) { const af = a.post.author.viewer?.following const bf = b.post.author.viewer?.following if (af && !bf) { return -1 } else if (!af && bf) { return 1 } } // Split items from different fetches into separate generations. let aFetchedAt = fetchedAtCache.get(a.uri) if (aFetchedAt === undefined) { fetchedAtCache.set(a.uri, fetchedAt) aFetchedAt = fetchedAt } let bFetchedAt = fetchedAtCache.get(b.uri) if (bFetchedAt === undefined) { fetchedAtCache.set(b.uri, fetchedAt) bFetchedAt = fetchedAt } if (aFetchedAt !== bFetchedAt) { return aFetchedAt - bFetchedAt // older fetches first } else if (opts.sort === 'hotness') { const aHotness = getHotness(a, aFetchedAt) const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */) return bHotness - aHotness } else if (opts.sort === 'oldest') { return a.post.indexedAt.localeCompare(b.post.indexedAt) } else if (opts.sort === 'newest') { return b.post.indexedAt.localeCompare(a.post.indexedAt) } else if (opts.sort === 'most-likes') { if (a.post.likeCount === b.post.likeCount) { return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest } else { return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes } } else if (opts.sort === 'random') { let aRandomScore = randomCache.get(a.uri) if (aRandomScore === undefined) { aRandomScore = Math.random() randomCache.set(a.uri, aRandomScore) } let bRandomScore = randomCache.get(b.uri) if (bRandomScore === undefined) { bRandomScore = Math.random() randomCache.set(b.uri, bRandomScore) } // this is vaguely criminal but we can get away with it return aRandomScore - bRandomScore } else { return b.post.indexedAt.localeCompare(a.post.indexedAt) } }) node.replies.forEach(reply => sortThread( reply, opts, modCache, currentDid, justPostedUris, threadgateRecordHiddenReplies, fetchedAtCache, fetchedAt, randomCache, ), ) } return node } // internal methods // = // Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html // We want to give recent comments a real chance (and not bury them deep below the fold) // while also surfacing well-liked comments from the past. In the future, we can explore // something more sophisticated, but we don't have much data on the client right now. function getHotness(threadPost: ThreadPost, fetchedAt: number) { const {post, hasOPLike} = threadPost const hoursAgo = Math.max( 0, (new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) / (1000 * 60 * 60), ) const likeCount = post.likeCount ?? 0 const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0) const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount)) const opLikeBoost = hasOPLike ? 0.8 : 1.0 const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost) return likeOrder / timePenalty } function responseToThreadNodes( node: ThreadViewNode, depth = 0, direction: 'up' | 'down' | 'start' = 'start', ): ThreadNode { if ( AppBskyFeedDefs.isThreadViewPost(node) && bsky.dangerousIsType( node.post.record, AppBskyFeedPost.isRecord, ) ) { const post = node.post // These should normally be present. They're missing only for // posts that were *just* created. Ideally, the backend would // know to return zeros. Fill them in manually to compensate. post.replyCount ??= 0 post.likeCount ??= 0 post.repostCount ??= 0 return { type: 'post', _reactKey: node.post.uri, uri: node.post.uri, post: post, record: node.post.record, parent: node.parent && direction !== 'down' ? responseToThreadNodes(node.parent, depth - 1, 'up') : undefined, replies: node.replies?.length && direction !== 'up' ? node.replies .map(reply => responseToThreadNodes(reply, depth + 1, 'down')) // do not show blocked posts in replies .filter(node => node.type !== 'blocked') : undefined, hasOPLike: Boolean(node?.threadContext?.rootAuthorLike), ctx: { depth, isHighlightedPost: depth === 0, hasMore: direction === 'down' && !node.replies?.length && !!post.replyCount, isSelfThread: false, // populated `annotateSelfThread` hasMoreSelfThread: false, // populated in `annotateSelfThread` }, } } else if (AppBskyFeedDefs.isBlockedPost(node)) { return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}} } else if (AppBskyFeedDefs.isNotFoundPost(node)) { return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}} } else { return {type: 'unknown', uri: ''} } } function annotateSelfThread(thread: ThreadNode) { if (thread.type !== 'post') { return } const selfThreadNodes: ThreadPost[] = [thread] let parent: ThreadNode | undefined = thread.parent while (parent) { if ( parent.type !== 'post' || parent.post.author.did !== thread.post.author.did ) { // not a self-thread return } selfThreadNodes.unshift(parent) parent = parent.parent } let node = thread for (let i = 0; i < 10; i++) { const reply = node.replies?.find( r => r.type === 'post' && r.post.author.did === thread.post.author.did, ) if (reply?.type !== 'post') { break } selfThreadNodes.push(reply) node = reply } if (selfThreadNodes.length > 1) { for (const selfThreadNode of selfThreadNodes) { selfThreadNode.ctx.isSelfThread = true } const last = selfThreadNodes[selfThreadNodes.length - 1] if ( last && last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth last.post.replyCount && // has replies !last.replies?.length // replies were not hydrated ) { last.ctx.hasMoreSelfThread = true } } } function findPostInQueryData( queryClient: QueryClient, uri: string, ): ThreadNode | void { let partial for (let item of findAllPostsInQueryData(queryClient, uri)) { if (item.type === 'post') { // Currently, the backend doesn't send full post info in some cases // (for example, for quoted posts). We use missing `likeCount` // as a way to detect that. In the future, we should fix this on // the backend, which will let us always stop on the first result. const hasAllInfo = item.post.likeCount != null if (hasAllInfo) { return item } else { partial = item // Keep searching, we might still find a full post in the cache. } } } return partial } export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { const atUri = new AtUri(uri) const queryDatas = queryClient.getQueriesData({ queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData) { continue } const {thread} = queryData for (const item of traverseThread(thread)) { if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) { const placeholder = threadNodeToPlaceholderThread(item) if (placeholder) { yield placeholder } } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { yield embedViewRecordToPlaceholderThread(quotedPost) } } } for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { // Check notifications first. If you have a post in notifications, // it's often due to a like or a repost, and we want to prioritize // a post object with >0 likes/reposts over a stale version with no // metrics in order to avoid a notification->post scroll jump. yield postViewToPlaceholderThread(post) } for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { yield postViewToPlaceholderThread(post) } for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { yield postViewToPlaceholderThread(post) } for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { yield postViewToPlaceholderThread(post) } for (let post of findAllPostsInExploreFeedPreviewsQueryData( queryClient, uri, )) { yield postViewToPlaceholderThread(post) } } export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, ): Generator { const queryDatas = queryClient.getQueriesData({ queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData) { continue } const {thread} = queryData for (const item of traverseThread(thread)) { if (item.type === 'post' && item.post.author.did === did) { yield item.post.author } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined if (quotedPost?.author.did === did) { yield quotedPost?.author } } } for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) { yield profile } for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) { yield profile } for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) { yield profile } for (let profile of findAllProfilesInExploreFeedPreviewsQueryData( queryClient, did, )) { yield profile } } function* traverseThread(node: ThreadNode): Generator { if (node.type === 'post') { if (node.parent) { yield* traverseThread(node.parent) } yield node if (node.replies?.length) { for (const reply of node.replies) { yield* traverseThread(reply) } } } } function threadNodeToPlaceholderThread( node: ThreadNode, ): ThreadNode | undefined { if (node.type !== 'post') { return undefined } return { type: node.type, _reactKey: node._reactKey, uri: node.uri, post: node.post, record: node.record, parent: undefined, replies: undefined, hasOPLike: undefined, ctx: { depth: 0, isHighlightedPost: true, hasMore: false, isParentLoading: !!node.record.reply, isChildLoading: !!node.post.replyCount, }, } } function postViewToPlaceholderThread( post: AppBskyFeedDefs.PostView, ): ThreadNode { return { type: 'post', _reactKey: post.uri, uri: post.uri, post: post, record: post.record as AppBskyFeedPost.Record, // validated in notifs parent: undefined, replies: undefined, hasOPLike: undefined, ctx: { depth: 0, isHighlightedPost: true, hasMore: false, isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, isChildLoading: true, // assume yes (show the spinner) just in case }, } } function embedViewRecordToPlaceholderThread( record: AppBskyEmbedRecord.ViewRecord, ): ThreadNode { return { type: 'post', _reactKey: record.uri, uri: record.uri, post: embedViewRecordToPostView(record), record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost parent: undefined, replies: undefined, hasOPLike: undefined, ctx: { depth: 0, isHighlightedPost: true, hasMore: false, isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, isChildLoading: true, // not available, so assume yes (to show the spinner) }, } }