Files
bsky-social-app/src/state/queries/usePostThread/utils.ts
T
2025-10-14 16:22:59 -05:00

186 lines
4.9 KiB
TypeScript

import {
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
AtUri,
} from '@atproto/api'
import {
type ApiThreadItem,
type ThreadItem,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
import {isDevMode} from '#/storage/hooks/dev-mode'
import * as bsky from '#/types/bsky'
export function getThreadgateRecord(
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
) {
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
view?.record,
AppBskyFeedThreadgate.isRecord,
)
? view?.record
: undefined
}
export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
/**
* If the record has no `reply` field, it is a root post.
*/
if (!post.record.reply) {
return new AtUri(post.uri)
}
if (post.record.reply?.root?.uri) {
return new AtUri(post.record.reply.root.uri)
}
}
}
export function getPostRecord(post: AppBskyFeedDefs.PostView) {
return post.record as AppBskyFeedPost.Record
}
export function getTraversalMetadata({
item,
prevItem,
nextItem,
parentMetadata,
}: {
item: ApiThreadItem
prevItem?: ApiThreadItem
nextItem?: ApiThreadItem
parentMetadata?: TraversalMetadata
}): TraversalMetadata {
if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
throw new Error(`Expected thread item to be a post`)
}
const rootUri = getRootPostAtUri(item.value.post)
const rootAuthorDid = rootUri?.host
const isRoot = item.uri === item.value.post.uri
const isPartOfOPThreadFromRoot = Boolean(
item.value.post.author.did === rootAuthorDid &&
(parentMetadata?.isRoot || parentMetadata?.isPartOfOPThreadFromRoot),
)
const repliesCount = item.value.post.replyCount || 0
const repliesUnhydrated = item.value.moreReplies || 0
const metadata = {
depth: item.depth,
/*
* Unknown until after traversal
*/
isLastChild: false,
/*
* Unknown until after traversal
*/
isLastSibling: false,
/*
* If it's a top level reply, bc we render each top-level branch as a
* separate tree, it's implicitly part of the last branch. For subsequent
* replies, we'll override this after traversal.
*/
isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
isPartOfOPThreadFromRoot,
isRoot,
nextItemDepth: nextItem?.depth,
parentMetadata,
prevItemDepth: prevItem?.depth,
/*
* Unknown until after traversal
*/
precedesChildReadMore: false,
/*
* Unknown until after traversal
*/
followsReadMoreUp: false,
postData: {
uri: item.uri,
authorHandle: item.value.post.author.handle,
},
repliesCount,
repliesUnhydrated,
repliesSeenCounter: 0,
replyIndex: 0,
skippedIndentIndices: new Set<number>(),
}
if (isDevMode()) {
// @ts-ignore dev only for debugging
metadata.postData.text = getPostRecord(item.value.post).text
}
return metadata
}
export function storeTraversalMetadata(
metadatas: Map<string, TraversalMetadata>,
metadata: TraversalMetadata,
) {
metadatas.set(metadata.postData.uri, metadata)
if (isDevMode()) {
// @ts-ignore dev only for debugging
metadatas.set(metadata.postData.text, metadata)
// @ts-ignore
window.__thread = metadatas
}
}
export function getThreadPostUI({
depth,
repliesCount,
prevItemDepth,
isLastChild,
isPartOfOPThreadFromRoot,
skippedIndentIndices,
repliesSeenCounter,
repliesUnhydrated,
precedesChildReadMore,
followsReadMoreUp,
}: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] {
const isReplyAndHasReplies =
depth > 0 &&
repliesCount > 0 &&
(repliesCount - repliesUnhydrated === repliesSeenCounter ||
repliesSeenCounter > 0)
return {
isAnchor: depth === 0,
showParentReplyLine:
followsReadMoreUp ||
(!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
showChildReplyLine: depth < 0 || isReplyAndHasReplies,
indent: isPartOfOPThreadFromRoot ? (depth < 1 ? 0 : 1) : depth,
/*
* If there are no slices below this one, or the next slice has a depth <=
* than the depth of this post, it's the last child of the reply tree. It
* is not necessarily the last leaf in the parent branch, since it could
* have another sibling.
*/
isLastChild,
skippedIndentIndices,
precedesChildReadMore: precedesChildReadMore ?? false,
}
}
export function getThreadPostNoUnauthenticatedUI({
depth,
prevItemDepth,
}: {
depth: number
prevItemDepth?: number
nextItemDepth?: number
}): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] {
return {
showChildReplyLine: depth < 0,
showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
}
}