From 9b48702df324596d66c99a693efa56e7aee740d5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 4 Jun 2025 18:55:09 -0500 Subject: [PATCH] Handle optimistic insert in hidden replies --- src/screens/PostThread/index.tsx | 6 +- src/state/queries/usePostThread/index.ts | 182 ++++++++---------- src/state/queries/usePostThread/queryCache.ts | 180 +++++++++-------- src/state/queries/usePostThread/traversal.ts | 18 +- src/state/queries/usePostThread/types.ts | 6 +- 5 files changed, 205 insertions(+), 187 deletions(-) diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 5fcaf99f9e..cd25ef1bbf 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -231,7 +231,8 @@ export function Inner({uri}: {uri: string | undefined}) { item={item} threadgateRecord={thread.data.threadgate?.record ?? undefined} overrides={{ - moderation: thread.state.hiddenItemsVisible && item.depth > 0, + moderation: + thread.state.hiddenThreadItemsVisible && item.depth > 0, }} onPostSuccess={optimisticOnPostReply} /> @@ -242,7 +243,8 @@ export function Inner({uri}: {uri: string | undefined}) { item={item} threadgateRecord={thread.data.threadgate?.record ?? undefined} overrides={{ - moderation: thread.state.hiddenItemsVisible && item.depth > 0, + moderation: + thread.state.hiddenThreadItemsVisible && item.depth > 0, }} onPostSuccess={optimisticOnPostReply} /> diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index 90fe7f1b11..fa786496e1 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -47,11 +47,14 @@ export function usePostThread({anchor}: {anchor?: string}) { view, prioritizeFollowedUsers, }) + const postThreadHiddenQueryKey = createPostThreadHiddenQueryKey({ + anchor, + prioritizeFollowedUsers, + }) const query = useQuery({ enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, queryKey: postThreadQueryKey, - // gcTime: 0, // TODO faster if we let it cache async queryFn(ctx) { const {data} = await wait( 400, @@ -117,39 +120,12 @@ export function usePostThread({anchor}: {anchor?: string}) { () => query.data?.threadgate, [query.data?.threadgate], ) - const hasServerHiddenItems = useMemo( + const hasServerHiddenThreadItems = useMemo( () => !!query.data?.hasHiddenReplies, [query.data?.hasHiddenReplies], ) - - const [hiddenItemsVisible, setHiddenItemsVisible] = useState(false) - const [serverHiddenThreadItems, setServerHiddenThreadItems] = useState< - ThreadItem[] - >([]) - - /** - * Sets the sort order for the thread and resets the hidden items - */ - const setSort: typeof baseSetSort = useCallback( - nextSort => { - setHiddenItemsVisible(false) - setServerHiddenThreadItems([]) - baseSetSort(nextSort) - }, - [baseSetSort, setServerHiddenThreadItems, setHiddenItemsVisible], - ) - - /** - * Sets the view variant for the thread and resets the hidden items - */ - const setView: typeof baseSetView = useCallback( - nextView => { - setHiddenItemsVisible(false) - setServerHiddenThreadItems([]) - baseSetView(nextView) - }, - [baseSetView, setServerHiddenThreadItems, setHiddenItemsVisible], - ) + const [hiddenThreadItemsVisible, setHiddenThreadItemsVisible] = + useState(false) /** * Creates a mutator for the post thread cache. This is used to insert @@ -158,81 +134,93 @@ export function usePostThread({anchor}: {anchor?: string}) { const mutator = useMemo( () => createCacheMutator({ - params: { - sort, - view, - }, - queryKey: postThreadQueryKey, + params: {view}, + postThreadQueryKey, + postThreadHiddenQueryKey, queryClient: qc, }), - [qc, sort, view, postThreadQueryKey], + [qc, view, postThreadQueryKey, postThreadHiddenQueryKey], ) /** - * Loads hidden replies for this thread. Any replies that are moderated from - * the initial visible response(s) are shown immediately. Remote data is - * fetched and inserted when it's available. + * If we have server-hidden items and the user has chosen to view them, + * start loading data */ - const loadServerHiddenThreadItems = useCallback(async () => { - /* - * 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. - */ - setHiddenItemsVisible(true) - - /* - * If there are no server hidden replies, just stop here. - */ - if (!hasServerHiddenItems) return - - setServerHiddenThreadItems( - Array.from({length: 2}).map((_, i) => + const hiddenQueryEnabled = + hasServerHiddenThreadItems && hiddenThreadItemsVisible + const hiddenQuery = useQuery({ + enabled: hiddenQueryEnabled, + queryKey: postThreadHiddenQueryKey, + async queryFn() { + const {data} = await wait( + 400, + agent.app.bsky.unspecced.getPostThreadHiddenV2({ + anchor: anchor!, + prioritizeFollowedUsers, + }), + ) + return data + }, + }) + const serverHiddenThreadItems: ThreadItem[] = useMemo(() => { + if (!hiddenQueryEnabled) return [] + if (hiddenQuery.isLoading) { + return Array.from({length: 2}).map((_, i) => views.skeleton({ - key: `${anchor!}-reply-${i}`, + key: `hidden-reply-${i}`, item: 'reply', }), - ), - ) - - const params = { - anchor: anchor!, - prioritizeFollowedUsers, - } - const data = await wait( - 400, - qc.fetchQuery({ - queryKey: createPostThreadHiddenQueryKey(params), - async queryFn() { - const {data} = await agent.app.bsky.unspecced.getPostThreadHiddenV2( - params, - ) - return data.thread || [] + ) + } else if (hiddenQuery.isError) { + // TODO could insert error component + return [] + } else if (hiddenQuery.data?.thread) { + const {threadItems} = sortAndAnnotateThreadItems( + hiddenQuery.data.thread, + { + view, + skipHiddenReplyHandling: true, + threadgateHiddenReplies: mergeThreadgateHiddenReplies( + threadgate?.record, + ), + moderationOpts: moderationOpts!, }, - }), - ) - - const {threadItems} = sortAndAnnotateThreadItems(data || [], { - view, - skipHiddenReplyHandling: true, - threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), - moderationOpts: moderationOpts!, - }) - - // insert the hidden replies into the state - setServerHiddenThreadItems(threadItems) + ) + return threadItems + } else { + return [] + } }, [ - qc, - agent, view, - anchor, - prioritizeFollowedUsers, + hiddenQueryEnabled, + hiddenQuery, mergeThreadgateHiddenReplies, moderationOpts, threadgate?.record, - hasServerHiddenItems, - setHiddenItemsVisible, ]) + /** + * Sets the sort order for the thread and resets the hidden items + */ + const setSort: typeof baseSetSort = useCallback( + nextSort => { + setHiddenThreadItemsVisible(false) + baseSetSort(nextSort) + }, + [baseSetSort, setHiddenThreadItemsVisible], + ) + + /** + * Sets the view variant for the thread and resets the hidden items + */ + const setView: typeof baseSetView = useCallback( + nextView => { + setHiddenThreadItemsVisible(false) + baseSetView(nextView) + }, + [baseSetView, setHiddenThreadItemsVisible], + ) + /* * This is the main thread response, sorted into separate buckets based on * moderation, and annotated with all UI state needed for rendering. @@ -263,9 +251,9 @@ export function usePostThread({anchor}: {anchor?: string}) { serverHiddenThreadItems, isLoading: query.isPlaceholderData, hasSession, - hasServerHiddenItems, - hiddenItemsVisible, - loadServerHiddenThreadItems, + hasServerHiddenThreadItems, + hiddenThreadItemsVisible, + showHiddenThreadItems: () => setHiddenThreadItemsVisible(true), }) }, [ threadItems, @@ -273,9 +261,9 @@ export function usePostThread({anchor}: {anchor?: string}) { serverHiddenThreadItems, query.isPlaceholderData, hasSession, - hasServerHiddenItems, - hiddenItemsVisible, - loadServerHiddenThreadItems, + hasServerHiddenThreadItems, + hiddenThreadItemsVisible, + setHiddenThreadItemsVisible, ]) return useMemo( @@ -292,7 +280,7 @@ export function usePostThread({anchor}: {anchor?: string}) { */ sort, view, - hiddenItemsVisible, + hiddenThreadItemsVisible, }, data: { items, @@ -314,7 +302,7 @@ export function usePostThread({anchor}: {anchor?: string}) { [ query, mutator.insertReplies, - hiddenItemsVisible, + hiddenThreadItemsVisible, sort, view, setSort, diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index dd3e25367b..e766542ed3 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -2,6 +2,7 @@ import { type $Typed, type AppBskyFeedDefs, AppBskyUnspeccedDefs, + type AppBskyUnspeccedGetPostThreadHiddenV2, type AppBskyUnspeccedGetPostThreadV2, AtUri, } from '@atproto/api' @@ -15,6 +16,8 @@ import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/ import {BELOW} from '#/state/queries/usePostThread/const' import {getBranch} from '#/state/queries/usePostThread/traversal' import { + type ApiThreadItem, + type createPostThreadHiddenQueryKey, type createPostThreadQueryKey, type PostThreadParams, postThreadQueryKeyRoot, @@ -26,96 +29,119 @@ import {embedViewRecordToPostView} from '#/state/queries/util' export function createCacheMutator({ queryClient, - queryKey, + postThreadQueryKey, + postThreadHiddenQueryKey, params, }: { queryClient: QueryClient - queryKey: ReturnType - // TODO could clean this up? - params: PostThreadParams + postThreadQueryKey: ReturnType + postThreadHiddenQueryKey: ReturnType + params: Pick }) { return { insertReplies( parentUri: string, replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], ) { + /* + * Main thread query mutator. + */ queryClient.setQueryData( - queryKey, - queryData => { - if (!queryData) return - - const thread = [...queryData.thread] - - for (let i = 0; i < thread.length; i++) { - const existingParent = thread[i] - if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) - continue - if (existingParent.uri !== parentUri) continue - - /* - * Update parent data - */ - existingParent.value.post = { - ...existingParent.value.post, - replyCount: (existingParent.value.post.replyCount || 0) + 1, - } - - const opDid = getRootPostAtUri(existingParent.value.post)?.host - const nextItem = thread.at(i + 1) - const isReplyToRoot = existingParent.depth === 0 - const isEndOfReplyChain = - !nextItem || nextItem.depth <= existingParent.depth - const firstReply = replies.at(0) - const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( - firstReply?.value, - ) - ? opDid === firstReply.value.post.author.did - : false - - /* - * Always insert replies if the following conditions are met. - */ - const shouldAlwaysInsertReplies = - isReplyToRoot || - params.view === 'tree' || - (params.view === 'linear' && isEndOfReplyChain) - /* - * Maybe insert replies if the replier is the OP and certain conditions are met - */ - const shouldReplaceWithOPReplies = - !isReplyToRoot && params.view === 'linear' && opIsReplier - - if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) { - const branch = getBranch(thread, i, existingParent.depth) - /* - * OP insertions replace other replies _in linear view_. - */ - const itemsToRemove = shouldReplaceWithOPReplies - ? branch.length - : 0 - - thread.splice( - i + 1, - itemsToRemove, - ...replies - .map((r, ri) => { - r.depth = existingParent.depth + 1 + ri - return r - }) - .filter(r => { - // Filter out replies that are too deep for our UI - return r.depth <= BELOW - }), - ) - } - } - + postThreadQueryKey, + data => { + if (!data) return return { - ...queryData, - thread, + ...data, + thread: mutator([ + ...data.thread, + ]), } }, ) + + /* + * Hidden threads query mutator. + */ + queryClient.setQueryData( + postThreadHiddenQueryKey, + data => { + if (!data) return + console.log(data) + return { + ...data, + thread: + mutator([ + ...data.thread, + ]), + } + }, + ) + + function mutator(thread: ApiThreadItem[]): T[] { + for (let i = 0; i < thread.length; i++) { + const existingParent = thread[i] + if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) + continue + if (existingParent.uri !== parentUri) continue + + /* + * Update parent data + */ + existingParent.value.post = { + ...existingParent.value.post, + replyCount: (existingParent.value.post.replyCount || 0) + 1, + } + + const opDid = getRootPostAtUri(existingParent.value.post)?.host + const nextItem = thread.at(i + 1) + const isReplyToRoot = existingParent.depth === 0 + const isEndOfReplyChain = + !nextItem || nextItem.depth <= existingParent.depth + const firstReply = replies.at(0) + const opIsReplier = AppBskyUnspeccedDefs.isThreadItemPost( + firstReply?.value, + ) + ? opDid === firstReply.value.post.author.did + : false + + /* + * Always insert replies if the following conditions are met. + */ + const shouldAlwaysInsertReplies = + isReplyToRoot || + params.view === 'tree' || + (params.view === 'linear' && isEndOfReplyChain) + /* + * Maybe insert replies if the replier is the OP and certain conditions are met + */ + const shouldReplaceWithOPReplies = + !isReplyToRoot && params.view === 'linear' && opIsReplier + + if (shouldAlwaysInsertReplies || shouldReplaceWithOPReplies) { + const branch = getBranch(thread, i, existingParent.depth) + /* + * OP insertions replace other replies _in linear view_. + */ + const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0 + + thread.splice( + i + 1, + itemsToRemove, + ...replies + .map((r, ri) => { + r.depth = existingParent.depth + 1 + ri + return r + }) + .filter(r => { + // Filter out replies that are too deep for our UI + return r.depth <= BELOW + }), + ) + } + } + + return thread as T[] + } }, /** * Unused atm, post shadow does the trick, but it would be nice to clean up @@ -123,7 +149,7 @@ export function createCacheMutator({ */ deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { queryClient.setQueryData( - queryKey, + postThreadQueryKey, queryData => { if (!queryData) return diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts index a566c10fb9..ff43fceaed 100644 --- a/src/state/queries/usePostThread/traversal.ts +++ b/src/state/queries/usePostThread/traversal.ts @@ -374,18 +374,18 @@ export function buildThread({ serverHiddenThreadItems, isLoading, hasSession, - hiddenItemsVisible, - hasServerHiddenItems, - loadServerHiddenThreadItems, + hiddenThreadItemsVisible, + hasServerHiddenThreadItems, + showHiddenThreadItems, }: { threadItems: ThreadItem[] hiddenThreadItems: ThreadItem[] serverHiddenThreadItems: ThreadItem[] isLoading: boolean hasSession: boolean - hiddenItemsVisible: boolean - hasServerHiddenItems: boolean - loadServerHiddenThreadItems: () => Promise + hiddenThreadItemsVisible: boolean + hasServerHiddenThreadItems: boolean + showHiddenThreadItems: () => void }) { /** * `threadItems` is memoized here, so don't mutate it directly. @@ -440,15 +440,15 @@ export function buildThread({ } } - if (hiddenThreadItems.length || hasServerHiddenItems) { - if (hiddenItemsVisible) { + if (hiddenThreadItems.length || hasServerHiddenThreadItems) { + if (hiddenThreadItemsVisible) { items.push(...hiddenThreadItems) items.push(...serverHiddenThreadItems) } else { items.push({ type: 'showHiddenReplies', key: 'showHiddenReplies', - onPress: loadServerHiddenThreadItems, + onPress: showHiddenThreadItems, }) } } diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index c942b08554..7eca18dc16 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -19,7 +19,9 @@ export const createPostThreadQueryKey = (props: PostThreadParams) => [postThreadQueryKeyRoot, props] as const export const createPostThreadHiddenQueryKey = ( - props: AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams, + props: Omit & { + anchor?: string + }, ) => [postThreadHiddenQueryKeyRoot, props] as const export type PostThreadParams = Pick< @@ -89,7 +91,7 @@ export type ThreadItem = | { type: 'showHiddenReplies' key: string - onPress: () => Promise + onPress: () => void } | { type: 'readMore'