From fc4fd80392c85eac4e8e353a1c02fa157aa3cfe0 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 4 Jun 2025 14:25:34 -0500 Subject: [PATCH] Split up traversal and avoid multiple passes --- src/state/queries/usePostThread/index.ts | 31 +- src/state/queries/usePostThread/traversal.ts | 319 ++++++++++--------- src/state/queries/usePostThread/types.ts | 1 + src/state/queries/usePostThread/views.ts | 11 +- 4 files changed, 195 insertions(+), 167 deletions(-) diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index 0c9d1e3aa3..0d64cc7d56 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -8,7 +8,7 @@ import { createCacheMutator, getThreadPlaceholder, } from '#/state/queries/usePostThread/queryCache' -import {traverse} from '#/state/queries/usePostThread/traversal' +import {combine,traverse} from '#/state/queries/usePostThread/traversal' import { createPostThreadHiddenQueryKey, createPostThreadQueryKey, @@ -150,50 +150,44 @@ export function usePostThread({anchor}: {anchor?: string}) { }), ) - const items = traverse(data || [], { + const {items} = traverse(data || [], { + view, + skipHiddenReplyHandling: true, threadgateHiddenReplies: mergeThreadgateHiddenReplies( query.data?.threadgate?.record, ), moderationOpts: moderationOpts!, - hasSession, - view, - hasServerHiddenReplies, - hiddenRepliesVisible, - skipHiddenReplyHandling: true, - loadHiddenReplies, }) // insert the hidden replies into the state setAdditionalHiddenItems(items) }, [ + qc, agent, view, anchor, prioritizeFollowedUsers, - hasSession, mergeThreadgateHiddenReplies, moderationOpts, - qc, query.data?.threadgate?.record, hasServerHiddenReplies, - hiddenRepliesVisible, setHiddenRepliesVisible, ]) - const items = useMemo(() => { - const results = traverse(query.data?.thread || [], { + const combined = useMemo(() => { + const traversal = traverse(query.data?.thread || [], { + view: view, threadgateHiddenReplies: mergeThreadgateHiddenReplies( query.data?.threadgate?.record, ), moderationOpts: moderationOpts!, + }) + return combine(traversal, { hasSession, - view: view, hasServerHiddenReplies, hiddenRepliesVisible, loadHiddenReplies, }) - - return results.concat(additionalHiddenItems) }, [ query.data, mergeThreadgateHiddenReplies, @@ -203,9 +197,12 @@ export function usePostThread({anchor}: {anchor?: string}) { hasServerHiddenReplies, hiddenRepliesVisible, loadHiddenReplies, - additionalHiddenItems, ]) + const items = useMemo(() => { + return combined.concat(additionalHiddenItems) + }, [combined, additionalHiddenItems]) + if (query.isPlaceholderData) { const anchorPost = items.at(0) const skeletonReplies = diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts index 554361083f..3d526b10d9 100644 --- a/src/state/queries/usePostThread/traversal.ts +++ b/src/state/queries/usePostThread/traversal.ts @@ -23,21 +23,20 @@ export function traverse( { threadgateHiddenReplies, moderationOpts, - hasSession, view, - hasServerHiddenReplies, - hiddenRepliesVisible, skipHiddenReplyHandling, - loadHiddenReplies, }: { threadgateHiddenReplies: Set moderationOpts: ModerationOpts - hasSession: boolean 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 - loadHiddenReplies: () => Promise }, ) { const items: ThreadItem[] = [] @@ -80,6 +79,7 @@ export function traverse( depth: item.depth, value: item.value, moderationOpts, + threadgateHiddenReplies, }) items.push(post) @@ -104,6 +104,7 @@ export function traverse( depth: parent.depth, value: parent.value, moderationOpts, + threadgateHiddenReplies, }), ) } @@ -140,15 +141,12 @@ export function traverse( depth: item.depth, value: item.value, 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) @@ -201,21 +199,15 @@ export function traverse( depth: child.depth, value: child.value, 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 * entirely. To reveal these, the user must navigate to the * parent post directly. */ - if ( - childPostMod.blurred || - childPostMod.muted || - childPostIsHiddenByThreadgate - ) { + if (childPost.isBlurred) { ci = getBranch(thread, ci, child.depth).end } else { hidden.push(childPost) @@ -239,160 +231,189 @@ export function traverse( } } - if (!skipHiddenReplyHandling) { - if (hidden.length || hasServerHiddenReplies) { - if (hiddenRepliesVisible) { - items.push(...hidden) - } else { - items.push({ - type: 'showHiddenReplies', - key: 'showHiddenReplies', - onLoad: loadHiddenReplies, - }) - } - } - } + /* + * Both `items` and `hidden` 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 [items, hidden]) { + for (let i = 0; i < subset.length; i++) { + const item = subset[i] + const prevItem = subset.at(i - 1) + const nextItem = subset.at(i + 1) - for (let i = 0; i < items.length; i++) { - const item = items[i] - const prevItem = items.at(i - 1) - const nextItem = items.at(i + 1) + if (item.type === 'threadPost') { + const metadata = metadatas.get(item.uri) - if (item.type === 'threadPost') { - if ( - item.depth === 0 && - !item.value.post.viewer?.replyDisabled && - hasSession - ) { - items.splice(i + 1, 0, { - type: 'replyComposer', - key: 'replyComposer', - }) - i++ // skip next iteration - } + 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 - 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) { - /* - * 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 + /* + * 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 - /* - * 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 the parent is part of the last branch of the sub-tree, so is the child. + */ + if (metadata.parentMetadata.isPartOfLastBranchFromDepth) { + metadata.isPartOfLastBranchFromDepth = + metadata.parentMetadata.isPartOfLastBranchFromDepth + } + } - /* - * 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 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 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) { - metadata.isPartOfLastBranchFromDepth = - metadata.parentMetadata.isPartOfLastBranchFromDepth + 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 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 this post has unhydrated replies, and it is the last child, then + * it itself needs a "read more" */ - if ( - metadata.parentMetadata.repliesUnhydrated > 0 && - metadata.isLastSibling - ) { - metadata.upcomingParentReadMore = metadata.parentMetadata + if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) { + metadata.precedesChildReadMore = true + subset.splice(i + 1, 0, views.readMore(metadata)) + i++ // skip next iteration } /* - * Copy in the parent's upcoming read more, if it exists. Once we - * reach the bottom, we'll insert a "read more" + * 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 (metadata.parentMetadata.upcomingParentReadMore) { - metadata.upcomingParentReadMore = - metadata.parentMetadata.upcomingParentReadMore + if ( + view === 'tree' && + 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([ - ...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) - } + item.ui = getThreadPostUI(metadata) } - - /* - * 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 + }, +) { + 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 } diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index 50ecccd01f..13253b655e 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -40,6 +40,7 @@ export type ThreadItem = record: AppBskyFeedPost.Record } } + isBlurred: boolean moderation: ModerationDecision ui: { isAnchor: boolean diff --git a/src/state/queries/usePostThread/views.ts b/src/state/queries/usePostThread/views.ts index 99bae72b6f..a6f2f0f848 100644 --- a/src/state/queries/usePostThread/views.ts +++ b/src/state/queries/usePostThread/views.ts @@ -63,12 +63,20 @@ export function threadPost({ depth, value, moderationOpts, + threadgateHiddenReplies, }: { uri: string depth: number value: $Typed moderationOpts: ModerationOpts + threadgateHiddenReplies: Set }): Extract { + 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 { type: 'threadPost', key: uri, @@ -84,7 +92,8 @@ export function threadPost({ record: AppBskyFeedPost.Record }, }, - moderation: moderatePost(value.post, moderationOpts), + isBlurred, + moderation, // @ts-ignore populated by the traversal ui: {}, }