Files
bsky-social-app/src/state/queries/usePostThread/traversal.ts
T
2025-06-11 11:40:33 -05:00

453 lines
13 KiB
TypeScript

import {
AppBskyUnspeccedGetPostThreadV2,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {
HiddenReplyKind,
type PostThreadParams,
type Slice,
type TraversalMetadata,
} from '#/state/queries/usePostThread/types'
import {
getPostRecord,
getPostTraversalMetadata,
} from '#/state/queries/usePostThread/utils'
import * as views from '#/state/queries/usePostThread/views'
export function flatten(
sorted: ReturnType<typeof sort>,
{
hasSession,
showMuted,
showHidden,
view,
}: {
hasSession: boolean
showMuted: boolean
showHidden: boolean
view: PostThreadParams['view']
},
) {
const flattened: Slice[] = sorted.items
const parents = []
for (let i = 0; i < flattened.length; i++) {
const item = flattened[i]
if (item.type === 'threadPost') {
// TODO should not insert if not found post etc
if (
item.ui.isAnchor &&
hasSession &&
!item.value.post.viewer?.replyDisabled
) {
flattened.splice(i + 1, 0, {
type: 'replyComposer',
key: 'replyComposer',
})
}
const deepestParent = parents[parents.length - 1]
if (deepestParent) {
// next item is a sibling or an aunt/uncle
if (item.depth <= deepestParent.depth) {
for (let pi = parents.length - 1; pi >= 0; pi--) {
const parent = parents[pi]
if (item.depth <= parent.depth) {
/*
* Find the previous post item and set the read more flags
*/
for (let ui = i - 1; ui >= 0; ui--) {
let prev = flattened[ui]
if (prev.type === 'threadPost') {
prev.ui.precedesParentReadMore =
prev.ui.indent - 1 === parent.ui.indent // true
prev.ui.precedesChildReadMore =
prev.ui.indent === item.ui.indent
break
}
}
flattened.splice(
i + 1 + (pi - parents.length),
0,
views.readMore({
parent,
}),
)
parents.pop()
// skip next iteration
i++
if (view === 'linear') {
break
}
} else {
break
}
}
}
}
if (item.value.moreReplies > 0) {
parents.push(item)
}
const isLastIteration = i === flattened.length - 1
if (isLastIteration) {
const deepestParent = parents[parents.length - 1]
if (deepestParent) {
// next item is a sibling or an aunt/uncle
if (deepestParent.depth <= item.depth) {
for (let pi = parents.length - 1; pi >= 0; pi--) {
const parent = parents[pi]
if (parent.depth <= item.depth) {
/*
* Find the previous post item and set the read more flags
*/
for (let ui = i; ui >= 0; ui--) {
let prev = flattened[ui]
if (prev.type === 'threadPost') {
prev.ui.precedesParentReadMore =
prev.ui.indent - 1 === parent.ui.indent
prev.ui.precedesChildReadMore =
prev.ui.indent === item.ui.indent
break
}
}
flattened.splice(
i + 2 + (pi - parents.length),
0,
views.readMore({
parent,
}),
)
parents.pop()
// skip next iteration
i++
if (view === 'linear') {
break
}
} else {
break
}
}
}
}
}
}
}
console.log(flattened)
/*
* Insert hidden items and buttons to show them
*/
if (sorted.hidden.length) {
if (showHidden) {
flattened.push(...sorted.hidden)
if (sorted.muted.length) {
if (showMuted) {
flattened.push(...sorted.muted)
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showHiddenReplies',
kind: HiddenReplyKind.Hidden,
})
}
} else if (sorted.muted.length) {
if (showMuted) {
flattened.push(...sorted.muted)
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
return flattened
}
export function sort(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
{
threadgateHiddenReplies,
moderationOpts,
}: {
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
},
) {
const items: Slice[] = []
const hidden: Slice[] = []
const muted: Slice[] = []
const postDataMap = new Map<string, TraversalMetadata | undefined>()
traversal: for (let i = 0; i < thread.length; i++) {
const item = thread[i]
if (item.depth < 0) {
/*
* Parents are ignored until we find the highlighted post, then we walk
* _up_ from there.
*/
} else if (item.depth === 0) {
if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemNoUnauthenticated(
item.value,
)
) {
items.push(views.threadPostNoUnauthenticated(item))
} else if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemNotFound(item.value)
) {
items.push(views.threadPostNotFound(item))
} else if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemBlocked(item.value)
) {
items.push(views.threadPostBlocked(item))
} else if (AppBskyUnspeccedGetPostThreadV2.isThreadItemPost(item.value)) {
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
oneUp: thread[i - 1],
oneDown: thread[i + 1],
moderationOpts,
})
postDataMap.set(item.uri, getPostTraversalMetadata(post))
items.push(post)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parentOneDown = thread[pi + 1]
const parent = thread[pi]
const parentOneUp = thread[pi - 1]
if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemNoUnauthenticated(
parent.value,
)
) {
items.unshift(views.threadPostNoUnauthenticated(parent))
break parentTraversal
} else if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemNotFound(parent.value)
) {
items.unshift(views.threadPostNotFound(parent))
break parentTraversal
} else if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemBlocked(parent.value)
) {
items.unshift(views.threadPostBlocked(parent))
break parentTraversal
} else if (
AppBskyUnspeccedGetPostThreadV2.isThreadItemPost(parent.value)
) {
items.unshift(
views.threadPost({
uri: parent.uri,
depth: parent.depth,
value: parent.value,
oneUp: parentOneUp,
oneDown: parentOneDown,
moderationOpts,
}),
)
}
}
}
} 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 =
AppBskyUnspeccedGetPostThreadV2.isThreadItemNoUnauthenticated(
item.value,
) ||
AppBskyUnspeccedGetPostThreadV2.isThreadItemNotFound(item.value) ||
AppBskyUnspeccedGetPostThreadV2.isThreadItemBlocked(item.value)
if (shouldBreak) {
const branch = getBranch(thread, i, item.depth)
// could insert tombstone
i = branch.end
continue traversal
} else if (AppBskyUnspeccedGetPostThreadV2.isThreadItemPost(item.value)) {
const oneUp = thread.at(i - 1)
const oneDown = thread.at(i + 1)
const post = views.threadPost({
uri: item.uri,
depth: item.depth,
value: item.value,
traversalMetadata: postDataMap.get(
getPostRecord(item.value.post)?.reply?.parent?.uri || '',
),
oneUp,
oneDown,
moderationOpts,
})
postDataMap.set(item.uri, getPostTraversalMetadata(post))
const postMod = getModerationState(post.moderation)
const postIsHiddenByThreadgate = threadgateHiddenReplies.has(item.uri)
const postIsModerated =
postIsHiddenByThreadgate || postMod.blurred || postMod.muted
if (!postIsModerated) {
/*
* Not moderated, probably need to insert it
*/
items.push(post)
} else {
/*
* Moderated in some way, we're going to walk children
*/
const parent = post
const parentMod = postMod
const parentIsTopLevelReply = parent.depth === 1
const sortArray = parentMod.muted ? muted : hidden
// get sub tree
const branch = getBranch(thread, i, item.depth)
if (parentIsTopLevelReply) {
// push branch anchor into sorted array
sortArray.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 (
AppBskyUnspeccedGetPostThreadV2.isThreadItemPost(child.value)
) {
const childPost = views.threadPost({
uri: child.uri,
depth: child.depth,
value: child.value,
traversalMetadata: postDataMap.get(
getPostRecord(child.value.post)?.reply?.parent?.uri || '',
),
oneUp: thread[ci - 1],
oneDown: thread[ci + 1],
moderationOpts,
})
postDataMap.set(child.uri, getPostTraversalMetadata(childPost))
const childPostMod = getModerationState(childPost.moderation)
const childPostIsHiddenByThreadgate =
threadgateHiddenReplies.has(child.uri)
/*
* If a child is hidden in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the
* parent post directly.
*/
if (
childPostMod.blurred ||
childPostMod.muted ||
childPostIsHiddenByThreadgate
) {
ci = getBranch(thread, ci, child.depth).end
} else {
sortArray.push(childPost)
}
} else {
/*
* Drop the rest of the branch if we hit anything unexpected
*/
break
}
}
}
/*
* Skip to next branch
*/
i = branch.end
continue traversal
}
}
}
}
return {
items,
hidden,
muted,
}
}
/**
* 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)
* ├── branch ───── (0, 1)
* ├─┬ branch ───── (1, 1) (start)
* │ ├──┬ leaf ──── (2, 2)
* │ │ └── leaf ── (3, 3)
* │ └── leaf ───── (4, 2) (end)
* ├── branch ───── (5, 1)
* ├── branch ───── (6, 1)
*
* const { start: 1, end: 3 } = getBranch(items, 1, 1)
*/
export function getBranch(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
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,
}
}
export function getModerationState(moderation: ModerationDecision) {
const modui = moderation.ui('contentList')
const blurred = modui.blur || modui.filter
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
return {
blurred,
muted,
}
}