Split up traversal and avoid multiple passes

This commit is contained in:
Eric Bailey
2025-06-04 14:25:34 -05:00
parent f38811854b
commit fc4fd80392
4 changed files with 195 additions and 167 deletions
+14 -17
View File
@@ -8,7 +8,7 @@ import {
createCacheMutator, createCacheMutator,
getThreadPlaceholder, getThreadPlaceholder,
} from '#/state/queries/usePostThread/queryCache' } from '#/state/queries/usePostThread/queryCache'
import {traverse} from '#/state/queries/usePostThread/traversal' import {combine,traverse} from '#/state/queries/usePostThread/traversal'
import { import {
createPostThreadHiddenQueryKey, createPostThreadHiddenQueryKey,
createPostThreadQueryKey, createPostThreadQueryKey,
@@ -150,50 +150,44 @@ export function usePostThread({anchor}: {anchor?: string}) {
}), }),
) )
const items = traverse(data || [], { const {items} = traverse(data || [], {
view,
skipHiddenReplyHandling: true,
threadgateHiddenReplies: mergeThreadgateHiddenReplies( threadgateHiddenReplies: mergeThreadgateHiddenReplies(
query.data?.threadgate?.record, query.data?.threadgate?.record,
), ),
moderationOpts: moderationOpts!, moderationOpts: moderationOpts!,
hasSession,
view,
hasServerHiddenReplies,
hiddenRepliesVisible,
skipHiddenReplyHandling: true,
loadHiddenReplies,
}) })
// insert the hidden replies into the state // insert the hidden replies into the state
setAdditionalHiddenItems(items) setAdditionalHiddenItems(items)
}, [ }, [
qc,
agent, agent,
view, view,
anchor, anchor,
prioritizeFollowedUsers, prioritizeFollowedUsers,
hasSession,
mergeThreadgateHiddenReplies, mergeThreadgateHiddenReplies,
moderationOpts, moderationOpts,
qc,
query.data?.threadgate?.record, query.data?.threadgate?.record,
hasServerHiddenReplies, hasServerHiddenReplies,
hiddenRepliesVisible,
setHiddenRepliesVisible, setHiddenRepliesVisible,
]) ])
const items = useMemo(() => { const combined = useMemo(() => {
const results = traverse(query.data?.thread || [], { const traversal = traverse(query.data?.thread || [], {
view: view,
threadgateHiddenReplies: mergeThreadgateHiddenReplies( threadgateHiddenReplies: mergeThreadgateHiddenReplies(
query.data?.threadgate?.record, query.data?.threadgate?.record,
), ),
moderationOpts: moderationOpts!, moderationOpts: moderationOpts!,
})
return combine(traversal, {
hasSession, hasSession,
view: view,
hasServerHiddenReplies, hasServerHiddenReplies,
hiddenRepliesVisible, hiddenRepliesVisible,
loadHiddenReplies, loadHiddenReplies,
}) })
return results.concat(additionalHiddenItems)
}, [ }, [
query.data, query.data,
mergeThreadgateHiddenReplies, mergeThreadgateHiddenReplies,
@@ -203,9 +197,12 @@ export function usePostThread({anchor}: {anchor?: string}) {
hasServerHiddenReplies, hasServerHiddenReplies,
hiddenRepliesVisible, hiddenRepliesVisible,
loadHiddenReplies, loadHiddenReplies,
additionalHiddenItems,
]) ])
const items = useMemo(() => {
return combined.concat(additionalHiddenItems)
}, [combined, additionalHiddenItems])
if (query.isPlaceholderData) { if (query.isPlaceholderData) {
const anchorPost = items.at(0) const anchorPost = items.at(0)
const skeletonReplies = const skeletonReplies =
+170 -149
View File
@@ -23,21 +23,20 @@ export function traverse(
{ {
threadgateHiddenReplies, threadgateHiddenReplies,
moderationOpts, moderationOpts,
hasSession,
view, view,
hasServerHiddenReplies,
hiddenRepliesVisible,
skipHiddenReplyHandling, skipHiddenReplyHandling,
loadHiddenReplies,
}: { }: {
threadgateHiddenReplies: Set<string> threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
hasSession: boolean
view: PostThreadParams['view'] view: PostThreadParams['view']
hasServerHiddenReplies: boolean /**
hiddenRepliesVisible: boolean * Set to `true` in cases where we already know the moderation state of the
* post e.g. when fetching server-hidden replies. This will prevent
* additional sorting or nested-branch truncation, and all replies,
* regardless of moderation state, will be included in the resulting
* `items` array.
*/
skipHiddenReplyHandling?: boolean skipHiddenReplyHandling?: boolean
loadHiddenReplies: () => Promise<void>
}, },
) { ) {
const items: ThreadItem[] = [] const items: ThreadItem[] = []
@@ -80,6 +79,7 @@ export function traverse(
depth: item.depth, depth: item.depth,
value: item.value, value: item.value,
moderationOpts, moderationOpts,
threadgateHiddenReplies,
}) })
items.push(post) items.push(post)
@@ -104,6 +104,7 @@ export function traverse(
depth: parent.depth, depth: parent.depth,
value: parent.value, value: parent.value,
moderationOpts, moderationOpts,
threadgateHiddenReplies,
}), }),
) )
} }
@@ -140,15 +141,12 @@ export function traverse(
depth: item.depth, depth: item.depth,
value: item.value, value: item.value,
moderationOpts, moderationOpts,
threadgateHiddenReplies,
}) })
const postMod = getModerationState(post.moderation)
const postIsHiddenByThreadgate = threadgateHiddenReplies.has(item.uri)
const postIsModerated =
postIsHiddenByThreadgate || postMod.blurred || postMod.muted
if (!postIsModerated || skipHiddenReplyHandling) { if (!post.isBlurred || skipHiddenReplyHandling) {
/* /*
* Not moderated, probably need to insert it * Not moderated, need to insert it
*/ */
items.push(post) items.push(post)
@@ -201,21 +199,15 @@ export function traverse(
depth: child.depth, depth: child.depth,
value: child.value, value: child.value,
moderationOpts, moderationOpts,
threadgateHiddenReplies,
}) })
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 * If a child is hidden in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the * entirely. To reveal these, the user must navigate to the
* parent post directly. * parent post directly.
*/ */
if ( if (childPost.isBlurred) {
childPostMod.blurred ||
childPostMod.muted ||
childPostIsHiddenByThreadgate
) {
ci = getBranch(thread, ci, child.depth).end ci = getBranch(thread, ci, child.depth).end
} else { } else {
hidden.push(childPost) hidden.push(childPost)
@@ -239,160 +231,189 @@ export function traverse(
} }
} }
if (!skipHiddenReplyHandling) { /*
if (hidden.length || hasServerHiddenReplies) { * Both `items` and `hidden` now need to be traversed again to fully compute
if (hiddenRepliesVisible) { * UI state based on collected metadata. These arrays will be muted in situ.
items.push(...hidden) */
} else { for (const subset of [items, hidden]) {
items.push({ for (let i = 0; i < subset.length; i++) {
type: 'showHiddenReplies', const item = subset[i]
key: 'showHiddenReplies', const prevItem = subset.at(i - 1)
onLoad: loadHiddenReplies, const nextItem = subset.at(i + 1)
})
}
}
}
for (let i = 0; i < items.length; i++) { if (item.type === 'threadPost') {
const item = items[i] const metadata = metadatas.get(item.uri)
const prevItem = items.at(i - 1)
const nextItem = items.at(i + 1)
if (item.type === 'threadPost') { if (metadata) {
if ( if (metadata.parentMetadata) {
item.depth === 0 && /*
!item.value.post.viewer?.replyDisabled && * Track what's before/after now that we've applied moderation
hasSession */
) { if (prevItem?.type === 'threadPost')
items.splice(i + 1, 0, { metadata.prevItemDepth = prevItem?.depth
type: 'replyComposer', if (nextItem?.type === 'threadPost')
key: 'replyComposer', metadata.nextItemDepth = nextItem?.depth
})
i++ // skip next iteration
}
const metadata = metadatas.get(item.uri) /*
* We can now officially calculate `isLastSibling` and `isLastChild`
* based on the actual data that we've seen.
*/
metadata.isLastSibling =
metadata.replyIndex ===
metadata.parentMetadata.repliesSeenCount - 1
metadata.isLastChild =
metadata.nextItemDepth === undefined ||
metadata.nextItemDepth <= metadata.depth
if (metadata) { /*
if (metadata.parentMetadata) { * If this is the last sibling, it's implicitly part of the last
/* * branch of this sub-tree.
* Track what's before/after now that we've applied moderation */
*/ if (metadata.isLastSibling) {
if (prevItem?.type === 'threadPost') metadata.isPartOfLastBranchFromDepth = metadata.depth
metadata.prevItemDepth = prevItem?.depth
if (nextItem?.type === 'threadPost')
metadata.nextItemDepth = nextItem?.depth
/* /**
* We can now officially calculate `isLastSibling` and `isLastChild` * If the parent is part of the last branch of the sub-tree, so is the child.
* based on the actual data that we've seen. */
*/ if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
metadata.isLastSibling = metadata.isPartOfLastBranchFromDepth =
metadata.replyIndex === metadata.parentMetadata.repliesSeenCount - 1 metadata.parentMetadata.isPartOfLastBranchFromDepth
metadata.isLastChild = }
metadata.nextItemDepth === undefined || }
metadata.nextItemDepth <= metadata.depth
/* /*
* If this is the last sibling, it's implicitly part of the last * If this is the last sibling, and the parent has unhydrated replies,
* branch of this sub-tree. * at some point down the line we will need to show a "read more".
*/ */
if (metadata.isLastSibling) { if (
metadata.isPartOfLastBranchFromDepth = metadata.depth 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 the parent is part of the last branch of the sub-tree, so is the child. * 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.isPartOfLastBranchFromDepth) { if (
metadata.isPartOfLastBranchFromDepth = metadata.parentMetadata.repliesUnhydrated <= 0 &&
metadata.parentMetadata.isPartOfLastBranchFromDepth 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 is the last sibling, and the parent has unhydrated replies, * If this post has unhydrated replies, and it is the last child, then
* at some point down the line we will need to show a "read more". * it itself needs a "read more"
*/ */
if ( if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
metadata.parentMetadata.repliesUnhydrated > 0 && metadata.precedesChildReadMore = true
metadata.isLastSibling subset.splice(i + 1, 0, views.readMore(metadata))
) { i++ // skip next iteration
metadata.upcomingParentReadMore = metadata.parentMetadata
} }
/* /*
* Copy in the parent's upcoming read more, if it exists. Once we * Tree-view only.
* reach the bottom, we'll insert a "read more" *
* If there's an upcoming parent read more, this branch is part of the
* last branch of the sub-tree, and the item itself is the last child,
* insert the parent "read more".
*/ */
if (metadata.parentMetadata.upcomingParentReadMore) { if (
metadata.upcomingParentReadMore = view === 'tree' &&
metadata.parentMetadata.upcomingParentReadMore metadata.upcomingParentReadMore &&
metadata.isPartOfLastBranchFromDepth ===
metadata.upcomingParentReadMore.depth &&
metadata.isLastChild
) {
subset.splice(
i + 1,
0,
views.readMore(metadata.upcomingParentReadMore),
)
i++
} }
/* /*
* Copy in the parent's skipped indents * Calculate the final UI state for the thread item.
*/ */
metadata.skippedIndentIndices = new Set([ item.ui = getThreadPostUI(metadata)
...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
items.splice(i + 1, 0, views.readMore(metadata))
i++ // skip next iteration
}
/*
* Tree-view only.
*
* If there's an upcoming parent read more, this branch is part of the
* last branch of the sub-tree, and the item itself is the last child,
* insert the parent "read more".
*/
if (
view === 'tree' &&
metadata.upcomingParentReadMore &&
metadata.isPartOfLastBranchFromDepth ===
metadata.upcomingParentReadMore.depth &&
metadata.isLastChild
) {
items.splice(
i + 1,
0,
views.readMore(metadata.upcomingParentReadMore),
)
i++
}
/*
* Calculate the final UI state for the thread item.
*/
item.ui = getThreadPostUI(metadata)
} }
} }
} }
return {
items,
hidden,
}
}
export function combine(
{items, hidden}: {items: ThreadItem[]; hidden: ThreadItem[]},
{
hasSession,
hiddenRepliesVisible,
hasServerHiddenReplies,
loadHiddenReplies,
}: {
hasSession: boolean
hiddenRepliesVisible: boolean
hasServerHiddenReplies: boolean
loadHiddenReplies: () => Promise<void>
},
) {
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 (hidden.length || hasServerHiddenReplies) {
if (hiddenRepliesVisible) {
return items.concat(hidden)
} else {
return items.concat({
type: 'showHiddenReplies',
key: 'showHiddenReplies',
onLoad: loadHiddenReplies,
})
}
}
return items return items
} }
+1
View File
@@ -40,6 +40,7 @@ export type ThreadItem =
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
} }
} }
isBlurred: boolean
moderation: ModerationDecision moderation: ModerationDecision
ui: { ui: {
isAnchor: boolean isAnchor: boolean
+10 -1
View File
@@ -63,12 +63,20 @@ export function threadPost({
depth, depth,
value, value,
moderationOpts, moderationOpts,
threadgateHiddenReplies,
}: { }: {
uri: string uri: string
depth: number depth: number
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost> value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
threadgateHiddenReplies: Set<string>
}): Extract<ThreadItem, {type: 'threadPost'}> { }): Extract<ThreadItem, {type: 'threadPost'}> {
const moderation = moderatePost(value.post, moderationOpts)
const modui = moderation.ui('contentList')
const blurred = modui.blur || modui.filter
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
const isBlurred = hiddenByThreadgate || blurred || muted
return { return {
type: 'threadPost', type: 'threadPost',
key: uri, key: uri,
@@ -84,7 +92,8 @@ export function threadPost({
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
}, },
}, },
moderation: moderatePost(value.post, moderationOpts), isBlurred,
moderation,
// @ts-ignore populated by the traversal // @ts-ignore populated by the traversal
ui: {}, ui: {},
} }