import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedGetPostThread, AppBskyEmbedRecord, } from '@atproto/api' import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' import {getAgent} from '#/state/session' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' import {getEmbeddedPost} from './util' export const RQKEY = (uri: string) => ['post-thread', uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] export interface ThreadCtx { depth: number isHighlightedPost?: boolean hasMore?: boolean showChildReplyLine?: boolean showParentReplyLine?: boolean isParentLoading?: boolean isChildLoading?: 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 function usePostThreadQuery(uri: string | undefined) { const queryClient = useQueryClient() return useQuery({ gcTime: 0, queryKey: RQKEY(uri || ''), async queryFn() { const res = await getAgent().getPostThread({uri: uri!}) if (res.success) { const nodes = responseToThreadNodes(res.data.thread) precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution return nodes } return {type: 'unknown', uri: uri!} }, enabled: !!uri, placeholderData: () => { if (!uri) { return undefined } { const item = findPostInQueryData(queryClient, uri) if (item) { return threadNodeToPlaceholderThread(item) } } { const item = findPostInFeedQueryData(queryClient, uri) if (item) { return postViewToPlaceholderThread(item) } } { const item = findPostInNotifsQueryData(queryClient, uri) if (item) { return postViewToPlaceholderThread(item) } } return undefined }, }) } export function sortThread( node: ThreadNode, opts: UsePreferencesQueryResponse['threadViewPrefs'], ): 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 } 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 } 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)) } 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 ) { return { type: 'post', _reactKey: node.post.uri, uri: node.post.uri, post: node.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, showChildReplyLine: direction === 'up' || (direction === 'down' && !!node.replies?.length), showParentReplyLine: (direction === 'up' && !!node.parent) || (direction === 'down' && depth !== 1), }, } } 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 findPostInQueryData( queryClient: QueryClient, uri: string, ): ThreadNode | undefined { const generator = findAllPostsInQueryData(queryClient, uri) const result = generator.next() if (result.done) { return undefined } else { return result.value } } export function* findAllPostsInQueryData( queryClient: QueryClient, uri: string, ): Generator { const queryDatas = queryClient.getQueriesData({ queryKey: ['post-thread'], }) for (const [_queryKey, queryData] of queryDatas) { if (!queryData) { continue } for (const item of traverseThread(queryData)) { if (item.uri === uri) { yield item } const quotedPost = item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined if (quotedPost?.uri === uri) { yield embedViewRecordToPlaceholderThread(quotedPost) } } } } 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, showChildReplyLine: false, showParentReplyLine: 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, showChildReplyLine: false, showParentReplyLine: 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: { uri: record.uri, cid: record.cid, author: record.author, record: record.value, indexedAt: record.indexedAt, labels: record.labels, }, record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost parent: undefined, replies: undefined, ctx: { depth: 0, isHighlightedPost: true, hasMore: false, showChildReplyLine: false, showParentReplyLine: false, isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply, isChildLoading: true, // not available, so assume yes (to show the spinner) }, } }