Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
Something went wrong. Try again.
8.6 kB · 267 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268import {AtUri} from '@atproto/api'
import {makeProfileLink} from '#/lib/routes/links'import {type ThreadItem} from '#/state/queries/usePostThread/types'
export type ThreadPostItem = Extract<ThreadItem, {type: 'threadPost'}>
/** * Find the start of the selected author's self-reply chain, stopping before * another participant's post. Missing same-author parents can be fetched by * re-anchoring there, then resolving again when that response arrives. */export function getReaderRoot(items: ThreadItem[]): string | null { let current = items.find( (item): item is ThreadPostItem => item.type === 'threadPost' && item.depth === 0, ) if (!current) return null const did = current.value.post.author.did const visited = new Set<string>() while (!visited.has(current.uri)) { visited.add(current.uri) const parentUri = current.value.post.record.reply?.parent?.uri if (!parentUri) break const parent = items.find( (item): item is ThreadPostItem => item.type === 'threadPost' && item.uri === parentUri, ) if (!parent) { return new AtUri(parentUri).host === did ? parentUri : current.uri } if (parent.value.post.author.did !== did) break current = parent } return current.uri}
/** Whether the selected post has a same-author parent or direct reply. */export function hasReaderThread(items: ThreadItem[]): boolean { const anchor = items.find( (item): item is ThreadPostItem => item.type === 'threadPost' && item.depth === 0, ) if (!anchor) return false return ( getReaderRoot(items) !== anchor.uri || items.some( item => item.type === 'threadPost' && item.depth === 1 && item.value.post.author.did === anchor.value.post.author.did, ) )}
/** * A toggle rendered after a post, inside its bracket. Expanding it reveals the * post's actions and replies, so the post bodies themselves stay plain, * selectable text. */export type ReaderSeam = { expanded: boolean /** * Number of replies to this post that are not hydrated in the linear * response and are therefore not rendered anywhere on this screen. */ hiddenReplyCount: number /** * URI of the next post in the OP chain, which renders as its own segment. * Excluded from the seam's fetched replies. */ continuationUri: string href: string /** * True when this is the last post in the OP chain and no "Read more * replies" follows - the visual end of the thread on this screen. * Seams here get a trailing spacer so the bracket can close above it. */ isThreadEnd: boolean}
export type ReaderSegmentItem = { type: 'readerSegment' key: string uri: string depth: number item: ThreadPostItem /** The seam rendered after this post, within its bracket. */ seam: ReaderSeam /** * A "read more" that belonged immediately after this segment's post in the * linear thread. Rendered under the seam (and hidden while the seam is * expanded) so its connector can meet the bracket hairline. */ trailingReadMore?: Extract<ThreadItem, {type: 'readMore'}>}
export type ReaderItem = ThreadItem | ReaderSegmentItem
/** * Transforms linear thread items into the reader view: the anchor post plus * the OP's contiguous self-thread collapse into continuous "segments" of plain * text, with a "seam" toggle after each post that reveals its actions and * replies, so a multi-part thread reads as one post. * * Items before the anchor (parents) pass through unchanged. If no OP chain is * found, the input is returned as-is. */export function buildReaderThread( items: ThreadItem[], {expandedSeamUri}: {expandedSeamUri: string | null},): { items: ReaderItem[] /** The anchor's seam, rendered inside the anchor post. */ anchorSeam?: ReaderSeam /** The open seam, if any. Resolved here to spare consumers a scan. */ expandedSeam?: ReaderSeam} { const noChain = { items: items as ReaderItem[], anchorSeam: undefined as ReaderSeam | undefined, expandedSeam: undefined as ReaderSeam | undefined, }
const anchorIndex = items.findIndex( item => item.type === 'threadPost' && item.depth === 0, ) if (anchorIndex === -1) return noChain
const anchor = items[anchorIndex] as ThreadPostItem const anchorDid = anchor.value.post.author.did
/* * Locate the start of the OP self-thread chain: a depth-1 reply by the * anchor author. The appview usually * serves it directly below the anchor, but depending on sort it may not be * the first sibling, so scan all hydrated depth-1 replies. */ const chainStart = items.findIndex( (item, i) => i > anchorIndex && item.type === 'threadPost' && item.depth === 1 && item.value.post.author.did === anchorDid, ) if (chainStart === -1) return noChain
/* * Collect the contiguous chain. Branches are served depth-first, one post * per level in linear view, so the chain is a contiguous run of items each * one level deeper than the last. Anything else - a tombstone, a read more * link, a reply by someone else - ends the chain, and we collapse only the * clean prefix. */ const chain: ThreadPostItem[] = [items[chainStart] as ThreadPostItem] let chainEnd = chainStart for (let i = chainStart + 1; i < items.length; i++) { const item = items[i] const prev = chain[chain.length - 1] if ( item.type === 'threadPost' && item.depth === prev.depth + 1 && item.value.post.author.did === anchorDid ) { chain.push(item) chainEnd = i } else { break } }
/* * A "read more" immediately following the chain belongs to the chain's * branch (sibling branches always start with a depth-1 post), so it is * attached to the last segment and rendered under its seam. */ let chainReadMore: Extract<ThreadItem, {type: 'readMore'}> | undefined const afterChain = items[chainEnd + 1] if (afterChain?.type === 'readMore') { chainReadMore = afterChain chainEnd++ }
const result: ReaderItem[] = items.slice(0, anchorIndex + 1)
let expandedSeam: ReaderSeam | undefined
/* * The anchor's seam has a continuation (the first OP-chain segment) already * on screen, so its count must exclude that reply - same idea as mid-chain * seams using moreReplies. createSeam subtracts the continuation when * fullCount is set. */ const anchorSeam = createSeam(anchor, { expandedSeamUri, continuationUri: chain[0].uri, fullCount: true, isThreadEnd: false, }) if (anchorSeam.expanded) expandedSeam = anchorSeam
for (let i = 0; i < chain.length; i++) { const post = chain[i] const continuation = chain[i + 1] const isLast = !continuation const seam = createSeam(post, { expandedSeamUri, continuationUri: continuation?.uri ?? '', fullCount: isLast, isThreadEnd: isLast && !chainReadMore, }) if (seam.expanded) expandedSeam = seam result.push({ type: 'readerSegment', key: `readerSegment:${post.uri}`, uri: post.uri, depth: post.depth, item: post, seam, /* * Attach to the last segment so the connector can meet the seam * hairline inside the same bracket. Omitted while that seam is * expanded - those unreplied descendants are behind the opened replies. */ trailingReadMore: isLast && chainReadMore && !seam.expanded ? chainReadMore : undefined, }) }
return {items: result, anchorSeam, expandedSeam}}
function createSeam( post: ThreadPostItem, { expandedSeamUri, continuationUri, fullCount, isThreadEnd, }: { expandedSeamUri: string | null continuationUri: string fullCount: boolean isThreadEnd: boolean },): ReaderSeam { const urip = new AtUri(post.uri) /* * Mid-chain seams have a continuation on screen, so use moreReplies (exact * with branchingFactor 1). The last post uses replyCount - every reply is * behind the seam. The anchor also uses replyCount but with a continuation, * so we subtract 1 for the chain start already rendered as a segment. */ const replyCount = post.value.post.replyCount ?? 0 const hiddenReplyCount = fullCount ? continuationUri ? Math.max(0, replyCount - 1) : replyCount : post.value.moreReplies || 0 return { expanded: expandedSeamUri === post.uri, hiddenReplyCount, continuationUri, href: makeProfileLink(post.value.post.author, 'post', urip.rkey), isThreadEnd, }}