import { AppBskyActorDefs, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedGetPostThread, AppBskyFeedPost, AtUri, ModerationDecision, ModerationOpts, } from '@atproto/api' import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useAgent} from '#/state/session' import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from 'state/queries/post-quotes' import { findAllPostsInQueryData as findAllPostsInSearchQueryData, findAllProfilesInQueryData as findAllProfilesInSearchQueryData, } from 'state/queries/search-posts' 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 replies?: ThreadNode[] 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 function usePostThreadQuery(uri: string | undefined) { const queryClient = useQueryClient() const agent = useAgent() return useQuery< {thread: ThreadNode; threadgate?: AppBskyFeedDefs.ThreadgateView}, Error >({ 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, ): 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 } } 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 } } 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') { return 0.5 - Math.random() // this is vaguely criminal but we can get away with it } return b.post.indexedAt.localeCompare(a.post.indexedAt) }) node.replies.forEach(reply => sortThread( reply, opts, modCache, currentDid, justPostedUris, threadgateRecordHiddenReplies, ), ) } return node } // internal methods // = function responseToThreadNodes( node: ThreadViewNode, depth = 0, direction: 'up' | 'down' | 'start' = 'start', ): ThreadNode { if ( AppBskyFeedDefs.isThreadViewPost(node) && AppBskyFeedPost.isRecord(node.post.record) && AppBskyFeedPost.validateRecord(node.post.record).success ) { 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, ctx: { depth, isHighlightedPost: depth === 0, hasMore: direction === 'down' && !node.replies?.length && !!node.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 } for (const item of traverseThread(queryData)) { 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 findAllPostsInFeedQueryData(queryClient, uri)) { yield postViewToPlaceholderThread(post) } for (let post of findAllPostsInNotifsQueryData(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) } } export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, ): Generator { const queryDatas = queryClient.getQueriesData({ queryKey: [RQKEY_ROOT], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData) { continue } for (const item of traverseThread(queryData)) { 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 } } 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, 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, 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, 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) }, } }