590 lines
19 KiB
TypeScript
590 lines
19 KiB
TypeScript
/* eslint-disable no-labels */
|
|
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
|
|
|
|
import {
|
|
type ApiThreadItem,
|
|
type PostThreadParams,
|
|
type ThreadItem,
|
|
type TraversalMetadata,
|
|
} from '#/state/queries/usePostThread/types'
|
|
import {
|
|
getPostRecord,
|
|
getReadMoreUI,
|
|
getThreadPostNoUnauthenticatedUI,
|
|
getThreadPostUI,
|
|
getTraversalMetadata,
|
|
storeTraversalMetadata,
|
|
} from '#/state/queries/usePostThread/utils'
|
|
import * as views from '#/state/queries/usePostThread/views'
|
|
|
|
export function sortAndAnnotateThreadItems(
|
|
thread: ApiThreadItem[],
|
|
{
|
|
threadgateHiddenReplies,
|
|
moderationOpts,
|
|
view,
|
|
skipModerationHandling,
|
|
}: {
|
|
threadgateHiddenReplies: Set<string>
|
|
moderationOpts: ModerationOpts
|
|
view: PostThreadParams['view']
|
|
/**
|
|
* Set to `true` in cases where we already know the moderation state of the
|
|
* post e.g. when fetching additional replies from the server. This will
|
|
* prevent additional sorting or nested-branch truncation, and all replies,
|
|
* regardless of moderation state, will be included in the resulting
|
|
* `threadItems` array.
|
|
*/
|
|
skipModerationHandling?: boolean
|
|
},
|
|
) {
|
|
const threadItems: ThreadItem[] = []
|
|
const otherThreadItems: ThreadItem[] = []
|
|
const metadatas = new Map<string, TraversalMetadata>()
|
|
|
|
traversal: for (let i = 0; i < thread.length; i++) {
|
|
const item = thread[i]
|
|
let parentMetadata: TraversalMetadata | undefined
|
|
let metadata: TraversalMetadata | undefined
|
|
|
|
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
|
parentMetadata = metadatas.get(
|
|
getPostRecord(item.value.post).reply?.parent?.uri || '',
|
|
)
|
|
metadata = getTraversalMetadata({
|
|
item,
|
|
parentMetadata,
|
|
prevItem: thread.at(i - 1),
|
|
nextItem: thread.at(i + 1),
|
|
})
|
|
storeTraversalMetadata(metadatas, metadata)
|
|
}
|
|
|
|
if (item.depth < 0) {
|
|
/*
|
|
* Parents are ignored until we find the anchor post, then we walk
|
|
* _up_ from there.
|
|
*/
|
|
} else if (item.depth === 0) {
|
|
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
|
|
threadItems.push(views.threadPostNoUnauthenticated(item))
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
|
|
threadItems.push(views.threadPostNotFound(item))
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
|
|
threadItems.push(views.threadPostBlocked(item))
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
|
const post = views.threadPost({
|
|
uri: item.uri,
|
|
depth: item.depth,
|
|
value: item.value,
|
|
moderationOpts,
|
|
threadgateHiddenReplies,
|
|
})
|
|
threadItems.push(post)
|
|
|
|
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
|
|
const parent = thread[pi]
|
|
|
|
if (
|
|
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
|
|
) {
|
|
const post = views.threadPostNoUnauthenticated(parent)
|
|
post.ui = getThreadPostNoUnauthenticatedUI({
|
|
depth: parent.depth,
|
|
// ignore for now
|
|
// prevItemDepth: thread[pi - 1]?.depth,
|
|
nextItemDepth: thread[pi + 1]?.depth,
|
|
})
|
|
threadItems.unshift(post)
|
|
// for now, break parent traversal at first no-unauthed
|
|
break parentTraversal
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
|
|
threadItems.unshift(views.threadPostNotFound(parent))
|
|
break parentTraversal
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
|
|
threadItems.unshift(views.threadPostBlocked(parent))
|
|
break parentTraversal
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
|
|
threadItems.unshift(
|
|
views.threadPost({
|
|
uri: parent.uri,
|
|
depth: parent.depth,
|
|
value: parent.value,
|
|
moderationOpts,
|
|
threadgateHiddenReplies,
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} else if (item.depth > 0) {
|
|
/*
|
|
* The API does not send down any unavailable replies, so this will
|
|
* always be false (for now). If we ever wanted to tombstone them here,
|
|
* we could.
|
|
*/
|
|
const shouldBreak =
|
|
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
|
|
AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
|
|
AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
|
|
|
|
if (shouldBreak) {
|
|
const branch = getBranch(thread, i, item.depth)
|
|
// could insert tombstone
|
|
i = branch.end
|
|
continue traversal
|
|
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
|
if (parentMetadata) {
|
|
/*
|
|
* Set this value before incrementing the `repliesSeenCounter` later
|
|
* on, since `repliesSeenCounter` is 1-indexed and `replyIndex` is
|
|
* 0-indexed.
|
|
*/
|
|
metadata!.replyIndex = parentMetadata.repliesSeenCounter
|
|
}
|
|
|
|
const post = views.threadPost({
|
|
uri: item.uri,
|
|
depth: item.depth,
|
|
value: item.value,
|
|
moderationOpts,
|
|
threadgateHiddenReplies,
|
|
})
|
|
|
|
if (!post.isBlurred || skipModerationHandling) {
|
|
const skip =
|
|
parentMetadata?.isPartOfOPThreadFromRoot &&
|
|
!metadata?.isPartOfOPThreadFromRoot
|
|
|
|
if (skip) {
|
|
const branch = getBranch(thread, i, item.depth)
|
|
// could insert tombstone
|
|
i = branch.end
|
|
continue traversal
|
|
}
|
|
|
|
/*
|
|
* Not moderated, need to insert it
|
|
*/
|
|
threadItems.push(post)
|
|
|
|
/*
|
|
* Update seen reply count of parent
|
|
*/
|
|
if (parentMetadata) {
|
|
parentMetadata.repliesSeenCounter += 1
|
|
}
|
|
} else {
|
|
/*
|
|
* Moderated in some way, we're going to walk children
|
|
*/
|
|
const parent = post
|
|
const parentIsTopLevelReply = parent.depth === 1
|
|
// get sub tree
|
|
const branch = getBranch(thread, i, item.depth)
|
|
|
|
if (parentIsTopLevelReply) {
|
|
// push branch anchor into sorted array
|
|
otherThreadItems.push(parent)
|
|
// skip branch anchor in branch traversal
|
|
const startIndex = branch.start + 1
|
|
|
|
for (let ci = startIndex; ci <= branch.end; ci++) {
|
|
const child = thread[ci]
|
|
|
|
if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
|
|
const childParentMetadata = metadatas.get(
|
|
getPostRecord(child.value.post).reply?.parent?.uri || '',
|
|
)
|
|
const childMetadata = getTraversalMetadata({
|
|
item: child,
|
|
prevItem: thread[ci - 1],
|
|
nextItem: thread[ci + 1],
|
|
parentMetadata: childParentMetadata,
|
|
})
|
|
storeTraversalMetadata(metadatas, childMetadata)
|
|
if (childParentMetadata) {
|
|
/*
|
|
* Set this value before incrementing the
|
|
* `repliesSeenCounter` later on, since `repliesSeenCounter`
|
|
* is 1-indexed and `replyIndex` is 0-indexed.
|
|
*/
|
|
childMetadata!.replyIndex =
|
|
childParentMetadata.repliesSeenCounter
|
|
}
|
|
|
|
const childPost = views.threadPost({
|
|
uri: child.uri,
|
|
depth: child.depth,
|
|
value: child.value,
|
|
moderationOpts,
|
|
threadgateHiddenReplies,
|
|
})
|
|
|
|
/*
|
|
* If a child is moderated in any way, drop it an its sub-branch
|
|
* entirely. To reveal these, the user must navigate to the
|
|
* parent post directly.
|
|
*/
|
|
if (childPost.isBlurred) {
|
|
ci = getBranch(thread, ci, child.depth).end
|
|
} else {
|
|
otherThreadItems.push(childPost)
|
|
|
|
if (childParentMetadata) {
|
|
childParentMetadata.repliesSeenCounter += 1
|
|
}
|
|
}
|
|
} else {
|
|
/*
|
|
* Drop the rest of the branch if we hit anything unexpected
|
|
*/
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Skip to next branch
|
|
*/
|
|
i = branch.end
|
|
continue traversal
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
|
|
* UI state based on collected metadata. These arrays will be muted in situ.
|
|
*/
|
|
for (const subset of [threadItems, otherThreadItems]) {
|
|
for (let i = 0; i < subset.length; i++) {
|
|
const item = subset[i]
|
|
const prevItem = subset.at(i - 1)
|
|
const nextItem = subset.at(i + 1)
|
|
|
|
if (item.type === 'threadPost') {
|
|
const metadata = metadatas.get(item.uri)
|
|
|
|
if (metadata) {
|
|
if (metadata.parentMetadata) {
|
|
/*
|
|
* Track what's before/after now that we've applied moderation
|
|
*/
|
|
if (prevItem?.type === 'threadPost')
|
|
metadata.prevItemDepth = prevItem?.depth
|
|
if (nextItem?.type === 'threadPost')
|
|
metadata.nextItemDepth = nextItem?.depth
|
|
|
|
/**
|
|
* Item is also the last "sibling" if its index matches the total
|
|
* number of replies we're actually able to render to the page.
|
|
*/
|
|
const isLastSiblingDueToMissingReplies =
|
|
metadata.replyIndex ===
|
|
metadata.parentMetadata.repliesSeenCounter - 1
|
|
|
|
/*
|
|
* Item can also be the last "sibling" if we know we don't have a
|
|
* next item, OR if that next item's depth is less than this item's
|
|
* depth (meaning it's a sibling of the parent, not a child of this
|
|
* item).
|
|
*/
|
|
const isImplicitlyLastSibling =
|
|
nextItem === undefined ||
|
|
metadata.nextItemDepth === undefined ||
|
|
metadata.nextItemDepth < metadata.depth
|
|
|
|
/*
|
|
* Ok now we can set the last sibling state.
|
|
*/
|
|
metadata.isLastSibling =
|
|
isImplicitlyLastSibling || isLastSiblingDueToMissingReplies
|
|
|
|
/*
|
|
* Item is the last "child" in a branch if there is no next item,
|
|
* or if the next item's depth is less than this item's depth (a
|
|
* sibling of the parent) or equal to this item's depth (a sibling
|
|
* of this item)
|
|
*/
|
|
metadata.isLastChild =
|
|
nextItem === undefined ||
|
|
metadata.nextItemDepth === undefined ||
|
|
metadata.nextItemDepth <= metadata.depth
|
|
|
|
/*
|
|
* If this is the last sibling, it's implicitly part of the last
|
|
* branch of this sub-tree.
|
|
*/
|
|
if (metadata.isLastSibling) {
|
|
metadata.isPartOfLastBranchFromDepth = metadata.depth
|
|
|
|
/**
|
|
* If the parent is part of the last branch of the sub-tree, so
|
|
* is the child. However, if the child is also a last sibling,
|
|
* then we need to start tracking `isPartOfLastBranchFromDepth`
|
|
* from this point onwards, always updating it to the depth of
|
|
* the last sibling as we go down.
|
|
*/
|
|
if (
|
|
!metadata.isLastSibling &&
|
|
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
|
) {
|
|
metadata.isPartOfLastBranchFromDepth =
|
|
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
|
}
|
|
}
|
|
|
|
/*
|
|
* If this is the last sibling, and the parent has unhydrated replies,
|
|
* at some point down the line we will need to show a "read more".
|
|
*/
|
|
if (
|
|
metadata.parentMetadata.repliesUnhydrated > 0 &&
|
|
metadata.isLastSibling
|
|
) {
|
|
metadata.upcomingParentReadMore = metadata.parentMetadata
|
|
}
|
|
|
|
/*
|
|
* Copy in the parent's upcoming read more, if it exists. Once we
|
|
* reach the bottom, we'll insert a "read more"
|
|
*/
|
|
if (metadata.parentMetadata.upcomingParentReadMore) {
|
|
metadata.upcomingParentReadMore =
|
|
metadata.parentMetadata.upcomingParentReadMore
|
|
}
|
|
|
|
/*
|
|
* Copy in the parent's skipped indents
|
|
*/
|
|
metadata.skippedIndentIndices = new Set([
|
|
...metadata.parentMetadata.skippedIndentIndices,
|
|
])
|
|
|
|
/**
|
|
* If this is the last sibling, and the parent has no unhydrated
|
|
* replies, then we know we can skip an indent line.
|
|
*/
|
|
if (
|
|
metadata.parentMetadata.repliesUnhydrated <= 0 &&
|
|
metadata.isLastSibling
|
|
) {
|
|
/**
|
|
* Depth is 2 more than the 0-index of the indent calculation
|
|
* bc of how we render these. So instead of handling that in the
|
|
* component, we just adjust that back to 0-index here.
|
|
*/
|
|
metadata.skippedIndentIndices.add(item.depth - 2)
|
|
}
|
|
}
|
|
|
|
/*
|
|
* If this post has unhydrated replies, and it is the last child, then
|
|
* it itself needs a "read more"
|
|
*/
|
|
if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
|
|
metadata.precedesChildReadMore = true
|
|
const view = views.readMore(metadata)
|
|
view.ui = getReadMoreUI(metadata)
|
|
subset.splice(i + 1, 0, view)
|
|
i++ // skip next iteration
|
|
}
|
|
|
|
/*
|
|
* Tree-view only.
|
|
*
|
|
* If there's an upcoming parent read more, this branch is part of a
|
|
* branch of the sub-tree that is deeper than the
|
|
* `upcomingParentReadMore`, and the item following the current item
|
|
* is either undefined or less-or-equal-to the depth of the
|
|
* `upcomingParentReadMore`, then we know it's time to drop in the
|
|
* parent read more.
|
|
*/
|
|
if (
|
|
view === 'tree' &&
|
|
metadata.upcomingParentReadMore &&
|
|
metadata.isPartOfLastBranchFromDepth &&
|
|
metadata.isPartOfLastBranchFromDepth >=
|
|
metadata.upcomingParentReadMore.depth &&
|
|
(metadata.nextItemDepth === undefined ||
|
|
metadata.nextItemDepth <= metadata.upcomingParentReadMore.depth)
|
|
) {
|
|
const view = views.readMore(metadata.upcomingParentReadMore)
|
|
view.ui = getReadMoreUI(metadata.upcomingParentReadMore)
|
|
subset.splice(i + 1, 0, view)
|
|
i++
|
|
}
|
|
|
|
/**
|
|
* Only occurs for the first item in the thread, which may have
|
|
* additional parents not included in this request.
|
|
*/
|
|
if (item.value.moreParents) {
|
|
metadata.followsReadMoreUp = true
|
|
subset.splice(i, 0, views.readMoreUp(metadata))
|
|
i++
|
|
}
|
|
|
|
/*
|
|
* Calculate the final UI state for the thread item.
|
|
*/
|
|
item.ui = getThreadPostUI(metadata)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
threadItems,
|
|
otherThreadItems,
|
|
}
|
|
}
|
|
|
|
export function buildThread({
|
|
threadItems,
|
|
otherThreadItems,
|
|
serverOtherThreadItems,
|
|
isLoading,
|
|
hasSession,
|
|
otherItemsVisible,
|
|
hasOtherThreadItems,
|
|
showOtherItems,
|
|
}: {
|
|
threadItems: ThreadItem[]
|
|
otherThreadItems: ThreadItem[]
|
|
serverOtherThreadItems: ThreadItem[]
|
|
isLoading: boolean
|
|
hasSession: boolean
|
|
otherItemsVisible: boolean
|
|
hasOtherThreadItems: boolean
|
|
showOtherItems: () => void
|
|
}) {
|
|
/**
|
|
* `threadItems` is memoized here, so don't mutate it directly.
|
|
*/
|
|
const items = [...threadItems]
|
|
|
|
if (isLoading) {
|
|
const anchorPost = items.at(0)
|
|
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
|
|
const skeletonReplies = hasAnchorFromCache
|
|
? (anchorPost.value.post.replyCount ?? 4)
|
|
: 4
|
|
|
|
if (!items.length) {
|
|
items.push(
|
|
views.skeleton({
|
|
key: 'anchor-skeleton',
|
|
item: 'anchor',
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (hasSession) {
|
|
// we might have this from cache
|
|
const replyDisabled =
|
|
hasAnchorFromCache &&
|
|
anchorPost.value.post.viewer?.replyDisabled === true
|
|
|
|
if (hasAnchorFromCache) {
|
|
if (!replyDisabled) {
|
|
items.push({
|
|
type: 'replyComposer',
|
|
key: 'replyComposer',
|
|
})
|
|
}
|
|
} else {
|
|
items.push(
|
|
views.skeleton({
|
|
key: 'replyComposer',
|
|
item: 'replyComposer',
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < skeletonReplies; i++) {
|
|
items.push(
|
|
views.skeleton({
|
|
key: `anchor-skeleton-reply-${i}`,
|
|
item: 'reply',
|
|
}),
|
|
)
|
|
}
|
|
} else {
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i]
|
|
if (
|
|
item.type === 'threadPost' &&
|
|
item.depth === 0 &&
|
|
!item.value.post.viewer?.replyDisabled &&
|
|
hasSession
|
|
) {
|
|
items.splice(i + 1, 0, {
|
|
type: 'replyComposer',
|
|
key: 'replyComposer',
|
|
})
|
|
break
|
|
}
|
|
}
|
|
|
|
if (otherThreadItems.length || hasOtherThreadItems) {
|
|
if (otherItemsVisible) {
|
|
items.push(...otherThreadItems)
|
|
items.push(...serverOtherThreadItems)
|
|
} else {
|
|
items.push({
|
|
type: 'showOtherReplies',
|
|
key: 'showOtherReplies',
|
|
onPress: showOtherItems,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
/**
|
|
* Get the start and end index of a "branch" of the thread. A "branch" is a
|
|
* parent and it's children (not siblings). Returned indices are inclusive of
|
|
* the parent and its last child.
|
|
*
|
|
* items[] (index, depth)
|
|
* └─┬ anchor ──────── (0, 0)
|
|
* ├─── branch ───── (1, 1)
|
|
* ├──┬ branch ───── (2, 1) (start)
|
|
* │ ├──┬ leaf ──── (3, 2)
|
|
* │ │ └── leaf ── (4, 3)
|
|
* │ └─── leaf ──── (5, 2) (end)
|
|
* ├─── branch ───── (6, 1)
|
|
* └─── branch ───── (7, 1)
|
|
*
|
|
* const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
|
|
*/
|
|
export function getBranch(
|
|
thread: ApiThreadItem[],
|
|
branchStartIndex: number,
|
|
branchStartDepth: number,
|
|
) {
|
|
let end = branchStartIndex
|
|
|
|
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
|
|
const next = thread[ci]
|
|
if (next.depth > branchStartDepth) {
|
|
end = ci
|
|
} else {
|
|
end = ci - 1
|
|
break
|
|
}
|
|
}
|
|
|
|
return {
|
|
start: branchStartIndex,
|
|
end,
|
|
length: end - branchStartIndex,
|
|
}
|
|
}
|