Clean up traversal functions further

This commit is contained in:
Eric Bailey
2025-06-04 16:29:32 -05:00
parent 66b52c1430
commit 3855fe7b18
4 changed files with 145 additions and 139 deletions
+1 -1
View File
@@ -293,7 +293,7 @@ export function Inner({uri}: {uri: string | undefined}) {
<PostThreadShowHiddenReplies <PostThreadShowHiddenReplies
type="hidden" type="hidden"
onPress={() => { onPress={() => {
item.onLoad() item.onPress()
/* /*
* Bit of a hack. This resets the ref value for the anchor so that * Bit of a hack. This resets the ref value for the anchor so that
* the next time `onContentSizeChangeWebOnly` fires, it won't * the next time `onContentSizeChangeWebOnly` fires, it won't
+40 -78
View File
@@ -8,7 +8,10 @@ import {
createCacheMutator, createCacheMutator,
getThreadPlaceholder, getThreadPlaceholder,
} from '#/state/queries/usePostThread/queryCache' } from '#/state/queries/usePostThread/queryCache'
import {combine, traverse} from '#/state/queries/usePostThread/traversal' import {
buildThread,
sortAndAnnotateThreadItems,
} from '#/state/queries/usePostThread/traversal'
import { import {
createPostThreadHiddenQueryKey, createPostThreadHiddenQueryKey,
createPostThreadQueryKey, createPostThreadQueryKey,
@@ -119,7 +122,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
) )
const [hiddenItemsVisible, setHiddenItemsVisible] = useState(false) const [hiddenItemsVisible, setHiddenItemsVisible] = useState(false)
const [additionalHiddenItems, setAdditionalHiddenItems] = useState< const [serverHiddenThreadItems, setServerHiddenThreadItems] = useState<
ThreadItem[] ThreadItem[]
>([]) >([])
@@ -129,10 +132,10 @@ export function usePostThread({anchor}: {anchor?: string}) {
const setSort: typeof baseSetSort = useCallback( const setSort: typeof baseSetSort = useCallback(
nextSort => { nextSort => {
setHiddenItemsVisible(false) setHiddenItemsVisible(false)
setAdditionalHiddenItems([]) setServerHiddenThreadItems([])
baseSetSort(nextSort) baseSetSort(nextSort)
}, },
[baseSetSort, setAdditionalHiddenItems, setHiddenItemsVisible], [baseSetSort, setServerHiddenThreadItems, setHiddenItemsVisible],
) )
/** /**
@@ -141,10 +144,10 @@ export function usePostThread({anchor}: {anchor?: string}) {
const setView: typeof baseSetView = useCallback( const setView: typeof baseSetView = useCallback(
nextView => { nextView => {
setHiddenItemsVisible(false) setHiddenItemsVisible(false)
setAdditionalHiddenItems([]) setServerHiddenThreadItems([])
baseSetView(nextView) baseSetView(nextView)
}, },
[baseSetView, setAdditionalHiddenItems, setHiddenItemsVisible], [baseSetView, setServerHiddenThreadItems, setHiddenItemsVisible],
) )
/** /**
@@ -169,7 +172,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
* the initial visible response(s) are shown immediately. Remote data is * the initial visible response(s) are shown immediately. Remote data is
* fetched and inserted when it's available. * fetched and inserted when it's available.
*/ */
const loadServerHiddenItems = useCallback(async () => { const loadServerHiddenThreadItems = useCallback(async () => {
/* /*
* Show any moderated replies already in memory that were handled here on * Show any moderated replies already in memory that were handled here on
* the client. If there are server-hidden replies, we'll fetch those next. * the client. If there are server-hidden replies, we'll fetch those next.
@@ -181,7 +184,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
*/ */
if (!hasServerHiddenItems) return if (!hasServerHiddenItems) return
setAdditionalHiddenItems( setServerHiddenThreadItems(
Array.from({length: 2}).map((_, i) => Array.from({length: 2}).map((_, i) =>
views.skeleton({ views.skeleton({
key: `${anchor!}-reply-${i}`, key: `${anchor!}-reply-${i}`,
@@ -207,7 +210,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
}), }),
) )
const {items} = traverse(data || [], { const {threadItems} = sortAndAnnotateThreadItems(data || [], {
view, view,
skipHiddenReplyHandling: true, skipHiddenReplyHandling: true,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
@@ -215,7 +218,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
}) })
// insert the hidden replies into the state // insert the hidden replies into the state
setAdditionalHiddenItems(items) setServerHiddenThreadItems(threadItems)
}, [ }, [
qc, qc,
agent, agent,
@@ -229,90 +232,49 @@ export function usePostThread({anchor}: {anchor?: string}) {
setHiddenItemsVisible, setHiddenItemsVisible,
]) ])
/** /*
* Builds the full set of thread items, minus any server-hidden replies. * This is the main thread response, sorted into separate buckets based on
* moderation, and annotated with all UI state needed for rendering.
*/ */
const threadItems = useMemo(() => { const {threadItems, hiddenThreadItems} = useMemo(() => {
const traversal = traverse(thread, { return sortAndAnnotateThreadItems(thread, {
view: view, view: view,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
moderationOpts: moderationOpts!, moderationOpts: moderationOpts!,
}) })
return combine(traversal, {
hasSession,
hasServerHiddenItems,
hiddenItemsVisible,
loadServerHiddenItems,
})
}, [ }, [
thread, thread,
threadgate?.record, threadgate?.record,
mergeThreadgateHiddenReplies, mergeThreadgateHiddenReplies,
moderationOpts, moderationOpts,
hasSession,
view, view,
hasServerHiddenItems,
hiddenItemsVisible,
loadServerHiddenItems,
]) ])
/** /*
* Computes the final thread items based on load state and the availability * Take all three sets of thread items and combine them into a single thread,
* of server-hidden replies. * along with any other thread items required for rendering e.g. "Show hidden
* replies" or the reply composer.
*/ */
const items = useMemo(() => { const items = useMemo(() => {
const result = [...threadItems] return buildThread({
if (query.isPlaceholderData) {
const anchorPost = result.at(0)
const skeletonReplies =
anchorPost && anchorPost.type === 'threadPost'
? anchorPost?.value.post.replyCount ?? 4
: 4
if (!result.length) {
result.push(
views.skeleton({
key: anchor!,
item: 'anchor',
}),
)
if (hasSession) {
result.push(
views.skeleton({
key: 'replyComposer',
item: 'replyComposer',
}),
)
}
}
for (let i = 0; i < skeletonReplies; i++) {
result.push(
views.skeleton({
key: `${anchor!}-reply-${i}`,
item: 'reply',
}),
)
}
return result
} else {
result.push(...additionalHiddenItems)
result.push({
type: 'bookend',
key: 'bookend-down',
direction: 'down',
})
return result
}
}, [
query.isPlaceholderData,
threadItems, threadItems,
additionalHiddenItems, hiddenThreadItems,
anchor, serverHiddenThreadItems,
isLoading: query.isPlaceholderData,
hasSession, hasSession,
hasServerHiddenItems,
hiddenItemsVisible,
loadServerHiddenThreadItems,
})
}, [
threadItems,
hiddenThreadItems,
serverHiddenThreadItems,
query.isPlaceholderData,
hasSession,
hasServerHiddenItems,
hiddenItemsVisible,
loadServerHiddenThreadItems,
]) ])
return useMemo( return useMemo(
@@ -350,7 +312,6 @@ export function usePostThread({anchor}: {anchor?: string}) {
}), }),
[ [
query, query,
items,
mutator.insertReplies, mutator.insertReplies,
hiddenItemsVisible, hiddenItemsVisible,
sort, sort,
@@ -358,6 +319,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
setSort, setSort,
setView, setView,
threadgate, threadgate,
items,
], ],
) )
} }
+79 -35
View File
@@ -14,7 +14,7 @@ import {
} from '#/state/queries/usePostThread/utils' } from '#/state/queries/usePostThread/utils'
import * as views from '#/state/queries/usePostThread/views' import * as views from '#/state/queries/usePostThread/views'
export function traverse( export function sortAndAnnotateThreadItems(
thread: ApiThreadItem[], thread: ApiThreadItem[],
{ {
threadgateHiddenReplies, threadgateHiddenReplies,
@@ -30,13 +30,13 @@ export function traverse(
* post e.g. when fetching server-hidden replies. This will prevent * post e.g. when fetching server-hidden replies. This will prevent
* additional sorting or nested-branch truncation, and all replies, * additional sorting or nested-branch truncation, and all replies,
* regardless of moderation state, will be included in the resulting * regardless of moderation state, will be included in the resulting
* `items` array. * `threadItems` array.
*/ */
skipHiddenReplyHandling?: boolean skipHiddenReplyHandling?: boolean
}, },
) { ) {
const items: ThreadItem[] = [] const threadItems: ThreadItem[] = []
const hidden: ThreadItem[] = [] const hiddenThreadItems: ThreadItem[] = []
const metadatas = new Map<string, TraversalMetadata>() const metadatas = new Map<string, TraversalMetadata>()
traversal: for (let i = 0; i < thread.length; i++) { traversal: for (let i = 0; i < thread.length; i++) {
@@ -64,11 +64,11 @@ export function traverse(
*/ */
} else if (item.depth === 0) { } else if (item.depth === 0) {
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) { if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
items.push(views.threadPostNoUnauthenticated(item)) threadItems.push(views.threadPostNoUnauthenticated(item))
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
items.push(views.threadPostNotFound(item)) threadItems.push(views.threadPostNotFound(item))
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
items.push(views.threadPostBlocked(item)) threadItems.push(views.threadPostBlocked(item))
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
const post = views.threadPost({ const post = views.threadPost({
uri: item.uri, uri: item.uri,
@@ -77,7 +77,7 @@ export function traverse(
moderationOpts, moderationOpts,
threadgateHiddenReplies, threadgateHiddenReplies,
}) })
items.push(post) threadItems.push(post)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) { parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parent = thread[pi] const parent = thread[pi]
@@ -85,16 +85,16 @@ export function traverse(
if ( if (
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value) AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
) { ) {
items.unshift(views.threadPostNoUnauthenticated(parent)) threadItems.unshift(views.threadPostNoUnauthenticated(parent))
break parentTraversal break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
items.unshift(views.threadPostNotFound(parent)) threadItems.unshift(views.threadPostNotFound(parent))
break parentTraversal break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
items.unshift(views.threadPostBlocked(parent)) threadItems.unshift(views.threadPostBlocked(parent))
break parentTraversal break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) { } else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
items.unshift( threadItems.unshift(
views.threadPost({ views.threadPost({
uri: parent.uri, uri: parent.uri,
depth: parent.depth, depth: parent.depth,
@@ -144,7 +144,7 @@ export function traverse(
/* /*
* Not moderated, need to insert it * Not moderated, need to insert it
*/ */
items.push(post) threadItems.push(post)
/* /*
* Update seen reply count of parent * Update seen reply count of parent
@@ -163,7 +163,7 @@ export function traverse(
if (parentIsTopLevelReply) { if (parentIsTopLevelReply) {
// push branch anchor into sorted array // push branch anchor into sorted array
hidden.push(parent) hiddenThreadItems.push(parent)
// skip branch anchor in branch traversal // skip branch anchor in branch traversal
const startIndex = branch.start + 1 const startIndex = branch.start + 1
@@ -206,7 +206,7 @@ export function traverse(
if (childPost.isBlurred) { if (childPost.isBlurred) {
ci = getBranch(thread, ci, child.depth).end ci = getBranch(thread, ci, child.depth).end
} else { } else {
hidden.push(childPost) hiddenThreadItems.push(childPost)
} }
} else { } else {
/* /*
@@ -228,10 +228,10 @@ export function traverse(
} }
/* /*
* Both `items` and `hidden` now need to be traversed again to fully compute * Both `threadItems` and `hiddenThreadItems` now need to be traversed again to fully compute
* UI state based on collected metadata. These arrays will be muted in situ. * UI state based on collected metadata. These arrays will be muted in situ.
*/ */
for (const subset of [items, hidden]) { for (const subset of [threadItems, hiddenThreadItems]) {
for (let i = 0; i < subset.length; i++) { for (let i = 0; i < subset.length; i++) {
const item = subset[i] const item = subset[i]
const prevItem = subset.at(i - 1) const prevItem = subset.at(i - 1)
@@ -363,36 +363,78 @@ export function traverse(
} }
return { return {
items, threadItems,
hidden, hiddenThreadItems,
} }
} }
export function combine( export function buildThread({
{items, hidden}: {items: ThreadItem[]; hidden: ThreadItem[]}, threadItems,
{ hiddenThreadItems,
serverHiddenThreadItems,
isLoading,
hasSession, hasSession,
hiddenItemsVisible, hiddenItemsVisible,
hasServerHiddenItems, hasServerHiddenItems,
loadServerHiddenItems, loadServerHiddenThreadItems,
}: { }: {
threadItems: ThreadItem[]
hiddenThreadItems: ThreadItem[]
serverHiddenThreadItems: ThreadItem[]
isLoading: boolean
hasSession: boolean hasSession: boolean
hiddenItemsVisible: boolean hiddenItemsVisible: boolean
hasServerHiddenItems: boolean hasServerHiddenItems: boolean
loadServerHiddenItems: () => Promise<void> loadServerHiddenThreadItems: () => Promise<void>
}, }) {
) { /**
const result = [...items] * `threadItems` is memoized here, so don't mutate it directly.
*/
const items = [...threadItems]
for (let i = 0; i < result.length; i++) { if (isLoading) {
const item = result[i] const anchorPost = items.at(0)
const skeletonReplies =
anchorPost && anchorPost.type === 'threadPost'
? anchorPost?.value.post.replyCount ?? 4
: 4
if (!items.length) {
items.push(
views.skeleton({
key: 'anchor-skeleton',
item: 'anchor',
}),
)
if (hasSession) {
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 ( if (
item.type === 'threadPost' && item.type === 'threadPost' &&
item.depth === 0 && item.depth === 0 &&
!item.value.post.viewer?.replyDisabled && !item.value.post.viewer?.replyDisabled &&
hasSession hasSession
) { ) {
result.splice(i + 1, 0, { items.splice(i + 1, 0, {
type: 'replyComposer', type: 'replyComposer',
key: 'replyComposer', key: 'replyComposer',
}) })
@@ -400,19 +442,21 @@ export function combine(
} }
} }
if (hidden.length || hasServerHiddenItems) { if (hiddenThreadItems.length || hasServerHiddenItems) {
if (hiddenItemsVisible) { if (hiddenItemsVisible) {
result.push(...hidden) items.push(...hiddenThreadItems)
items.push(...serverHiddenThreadItems)
} else { } else {
result.push({ items.push({
type: 'showHiddenReplies', type: 'showHiddenReplies',
key: 'showHiddenReplies', key: 'showHiddenReplies',
onLoad: loadServerHiddenItems, onPress: loadServerHiddenThreadItems,
}) })
} }
} }
}
return result return items
} }
/** /**
+1 -1
View File
@@ -89,7 +89,7 @@ export type ThreadItem =
| { | {
type: 'showHiddenReplies' type: 'showHiddenReplies'
key: string key: string
onLoad: () => Promise<void> onPress: () => Promise<void>
} }
| { | {
type: 'readMore' type: 'readMore'