Handle optimistic insert in hidden replies

This commit is contained in:
Eric Bailey
2025-06-04 18:55:09 -05:00
parent ab06956137
commit 9b48702df3
5 changed files with 205 additions and 187 deletions
+4 -2
View File
@@ -231,7 +231,8 @@ export function Inner({uri}: {uri: string | undefined}) {
item={item} item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined} threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{ overrides={{
moderation: thread.state.hiddenItemsVisible && item.depth > 0, moderation:
thread.state.hiddenThreadItemsVisible && item.depth > 0,
}} }}
onPostSuccess={optimisticOnPostReply} onPostSuccess={optimisticOnPostReply}
/> />
@@ -242,7 +243,8 @@ export function Inner({uri}: {uri: string | undefined}) {
item={item} item={item}
threadgateRecord={thread.data.threadgate?.record ?? undefined} threadgateRecord={thread.data.threadgate?.record ?? undefined}
overrides={{ overrides={{
moderation: thread.state.hiddenItemsVisible && item.depth > 0, moderation:
thread.state.hiddenThreadItemsVisible && item.depth > 0,
}} }}
onPostSuccess={optimisticOnPostReply} onPostSuccess={optimisticOnPostReply}
/> />
+82 -94
View File
@@ -47,11 +47,14 @@ export function usePostThread({anchor}: {anchor?: string}) {
view, view,
prioritizeFollowedUsers, prioritizeFollowedUsers,
}) })
const postThreadHiddenQueryKey = createPostThreadHiddenQueryKey({
anchor,
prioritizeFollowedUsers,
})
const query = useQuery<UsePostThreadQueryResult>({ const query = useQuery<UsePostThreadQueryResult>({
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
queryKey: postThreadQueryKey, queryKey: postThreadQueryKey,
// gcTime: 0, // TODO faster if we let it cache
async queryFn(ctx) { async queryFn(ctx) {
const {data} = await wait( const {data} = await wait(
400, 400,
@@ -117,39 +120,12 @@ export function usePostThread({anchor}: {anchor?: string}) {
() => query.data?.threadgate, () => query.data?.threadgate,
[query.data?.threadgate], [query.data?.threadgate],
) )
const hasServerHiddenItems = useMemo( const hasServerHiddenThreadItems = useMemo(
() => !!query.data?.hasHiddenReplies, () => !!query.data?.hasHiddenReplies,
[query.data?.hasHiddenReplies], [query.data?.hasHiddenReplies],
) )
const [hiddenThreadItemsVisible, setHiddenThreadItemsVisible] =
const [hiddenItemsVisible, setHiddenItemsVisible] = useState(false) 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],
)
/** /**
* Creates a mutator for the post thread cache. This is used to insert * 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( const mutator = useMemo(
() => () =>
createCacheMutator({ createCacheMutator({
params: { params: {view},
sort, postThreadQueryKey,
view, postThreadHiddenQueryKey,
},
queryKey: postThreadQueryKey,
queryClient: qc, queryClient: qc,
}), }),
[qc, sort, view, postThreadQueryKey], [qc, view, postThreadQueryKey, postThreadHiddenQueryKey],
) )
/** /**
* Loads hidden replies for this thread. Any replies that are moderated from * If we have server-hidden items and the user has chosen to view them,
* the initial visible response(s) are shown immediately. Remote data is * start loading data
* fetched and inserted when it's available.
*/ */
const loadServerHiddenThreadItems = useCallback(async () => { const hiddenQueryEnabled =
/* hasServerHiddenThreadItems && hiddenThreadItemsVisible
* Show any moderated replies already in memory that were handled here on const hiddenQuery = useQuery({
* the client. If there are server-hidden replies, we'll fetch those next. enabled: hiddenQueryEnabled,
*/ queryKey: postThreadHiddenQueryKey,
setHiddenItemsVisible(true) async queryFn() {
const {data} = await wait(
/* 400,
* If there are no server hidden replies, just stop here. agent.app.bsky.unspecced.getPostThreadHiddenV2({
*/
if (!hasServerHiddenItems) return
setServerHiddenThreadItems(
Array.from({length: 2}).map((_, i) =>
views.skeleton({
key: `${anchor!}-reply-${i}`,
item: 'reply',
}),
),
)
const params = {
anchor: anchor!, anchor: anchor!,
prioritizeFollowedUsers, 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 || []
},
}), }),
) )
return data
const {threadItems} = sortAndAnnotateThreadItems(data || [], { },
})
const serverHiddenThreadItems: ThreadItem[] = useMemo(() => {
if (!hiddenQueryEnabled) return []
if (hiddenQuery.isLoading) {
return Array.from({length: 2}).map((_, i) =>
views.skeleton({
key: `hidden-reply-${i}`,
item: 'reply',
}),
)
} else if (hiddenQuery.isError) {
// TODO could insert error component
return []
} else if (hiddenQuery.data?.thread) {
const {threadItems} = sortAndAnnotateThreadItems(
hiddenQuery.data.thread,
{
view, view,
skipHiddenReplyHandling: true, skipHiddenReplyHandling: true,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record), threadgateHiddenReplies: mergeThreadgateHiddenReplies(
threadgate?.record,
),
moderationOpts: moderationOpts!, moderationOpts: moderationOpts!,
}) },
)
// insert the hidden replies into the state return threadItems
setServerHiddenThreadItems(threadItems) } else {
return []
}
}, [ }, [
qc,
agent,
view, view,
anchor, hiddenQueryEnabled,
prioritizeFollowedUsers, hiddenQuery,
mergeThreadgateHiddenReplies, mergeThreadgateHiddenReplies,
moderationOpts, moderationOpts,
threadgate?.record, 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 * This is the main thread response, sorted into separate buckets based on
* moderation, and annotated with all UI state needed for rendering. * moderation, and annotated with all UI state needed for rendering.
@@ -263,9 +251,9 @@ export function usePostThread({anchor}: {anchor?: string}) {
serverHiddenThreadItems, serverHiddenThreadItems,
isLoading: query.isPlaceholderData, isLoading: query.isPlaceholderData,
hasSession, hasSession,
hasServerHiddenItems, hasServerHiddenThreadItems,
hiddenItemsVisible, hiddenThreadItemsVisible,
loadServerHiddenThreadItems, showHiddenThreadItems: () => setHiddenThreadItemsVisible(true),
}) })
}, [ }, [
threadItems, threadItems,
@@ -273,9 +261,9 @@ export function usePostThread({anchor}: {anchor?: string}) {
serverHiddenThreadItems, serverHiddenThreadItems,
query.isPlaceholderData, query.isPlaceholderData,
hasSession, hasSession,
hasServerHiddenItems, hasServerHiddenThreadItems,
hiddenItemsVisible, hiddenThreadItemsVisible,
loadServerHiddenThreadItems, setHiddenThreadItemsVisible,
]) ])
return useMemo( return useMemo(
@@ -292,7 +280,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
*/ */
sort, sort,
view, view,
hiddenItemsVisible, hiddenThreadItemsVisible,
}, },
data: { data: {
items, items,
@@ -314,7 +302,7 @@ export function usePostThread({anchor}: {anchor?: string}) {
[ [
query, query,
mutator.insertReplies, mutator.insertReplies,
hiddenItemsVisible, hiddenThreadItemsVisible,
sort, sort,
view, view,
setSort, setSort,
+43 -17
View File
@@ -2,6 +2,7 @@ import {
type $Typed, type $Typed,
type AppBskyFeedDefs, type AppBskyFeedDefs,
AppBskyUnspeccedDefs, AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadHiddenV2,
type AppBskyUnspeccedGetPostThreadV2, type AppBskyUnspeccedGetPostThreadV2,
AtUri, AtUri,
} from '@atproto/api' } from '@atproto/api'
@@ -15,6 +16,8 @@ import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/
import {BELOW} from '#/state/queries/usePostThread/const' import {BELOW} from '#/state/queries/usePostThread/const'
import {getBranch} from '#/state/queries/usePostThread/traversal' import {getBranch} from '#/state/queries/usePostThread/traversal'
import { import {
type ApiThreadItem,
type createPostThreadHiddenQueryKey,
type createPostThreadQueryKey, type createPostThreadQueryKey,
type PostThreadParams, type PostThreadParams,
postThreadQueryKeyRoot, postThreadQueryKeyRoot,
@@ -26,26 +29,55 @@ import {embedViewRecordToPostView} from '#/state/queries/util'
export function createCacheMutator({ export function createCacheMutator({
queryClient, queryClient,
queryKey, postThreadQueryKey,
postThreadHiddenQueryKey,
params, params,
}: { }: {
queryClient: QueryClient queryClient: QueryClient
queryKey: ReturnType<typeof createPostThreadQueryKey> postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
// TODO could clean this up? postThreadHiddenQueryKey: ReturnType<typeof createPostThreadHiddenQueryKey>
params: PostThreadParams params: Pick<PostThreadParams, 'view'>
}) { }) {
return { return {
insertReplies( insertReplies(
parentUri: string, parentUri: string,
replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[], replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
) { ) {
/*
* Main thread query mutator.
*/
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
queryKey, postThreadQueryKey,
queryData => { data => {
if (!queryData) return if (!data) return
return {
...data,
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
...data.thread,
]),
}
},
)
const thread = [...queryData.thread] /*
* Hidden threads query mutator.
*/
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadHiddenV2.OutputSchema>(
postThreadHiddenQueryKey,
data => {
if (!data) return
console.log(data)
return {
...data,
thread:
mutator<AppBskyUnspeccedGetPostThreadHiddenV2.ThreadHiddenItem>([
...data.thread,
]),
}
},
)
function mutator<T>(thread: ApiThreadItem[]): T[] {
for (let i = 0; i < thread.length; i++) { for (let i = 0; i < thread.length; i++) {
const existingParent = thread[i] const existingParent = thread[i]
if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value)) if (!AppBskyUnspeccedDefs.isThreadItemPost(existingParent.value))
@@ -90,9 +122,7 @@ export function createCacheMutator({
/* /*
* OP insertions replace other replies _in linear view_. * OP insertions replace other replies _in linear view_.
*/ */
const itemsToRemove = shouldReplaceWithOPReplies const itemsToRemove = shouldReplaceWithOPReplies ? branch.length : 0
? branch.length
: 0
thread.splice( thread.splice(
i + 1, i + 1,
@@ -110,20 +140,16 @@ export function createCacheMutator({
} }
} }
return { return thread as T[]
...queryData,
thread,
} }
}, },
)
},
/** /**
* Unused atm, post shadow does the trick, but it would be nice to clean up * Unused atm, post shadow does the trick, but it would be nice to clean up
* the whole sub-tree on deletes. * the whole sub-tree on deletes.
*/ */
deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) { deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>( queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
queryKey, postThreadQueryKey,
queryData => { queryData => {
if (!queryData) return if (!queryData) return
+9 -9
View File
@@ -374,18 +374,18 @@ export function buildThread({
serverHiddenThreadItems, serverHiddenThreadItems,
isLoading, isLoading,
hasSession, hasSession,
hiddenItemsVisible, hiddenThreadItemsVisible,
hasServerHiddenItems, hasServerHiddenThreadItems,
loadServerHiddenThreadItems, showHiddenThreadItems,
}: { }: {
threadItems: ThreadItem[] threadItems: ThreadItem[]
hiddenThreadItems: ThreadItem[] hiddenThreadItems: ThreadItem[]
serverHiddenThreadItems: ThreadItem[] serverHiddenThreadItems: ThreadItem[]
isLoading: boolean isLoading: boolean
hasSession: boolean hasSession: boolean
hiddenItemsVisible: boolean hiddenThreadItemsVisible: boolean
hasServerHiddenItems: boolean hasServerHiddenThreadItems: boolean
loadServerHiddenThreadItems: () => Promise<void> showHiddenThreadItems: () => void
}) { }) {
/** /**
* `threadItems` is memoized here, so don't mutate it directly. * `threadItems` is memoized here, so don't mutate it directly.
@@ -440,15 +440,15 @@ export function buildThread({
} }
} }
if (hiddenThreadItems.length || hasServerHiddenItems) { if (hiddenThreadItems.length || hasServerHiddenThreadItems) {
if (hiddenItemsVisible) { if (hiddenThreadItemsVisible) {
items.push(...hiddenThreadItems) items.push(...hiddenThreadItems)
items.push(...serverHiddenThreadItems) items.push(...serverHiddenThreadItems)
} else { } else {
items.push({ items.push({
type: 'showHiddenReplies', type: 'showHiddenReplies',
key: 'showHiddenReplies', key: 'showHiddenReplies',
onPress: loadServerHiddenThreadItems, onPress: showHiddenThreadItems,
}) })
} }
} }
+4 -2
View File
@@ -19,7 +19,9 @@ export const createPostThreadQueryKey = (props: PostThreadParams) =>
[postThreadQueryKeyRoot, props] as const [postThreadQueryKeyRoot, props] as const
export const createPostThreadHiddenQueryKey = ( export const createPostThreadHiddenQueryKey = (
props: AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams, props: Omit<AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams, 'anchor'> & {
anchor?: string
},
) => [postThreadHiddenQueryKeyRoot, props] as const ) => [postThreadHiddenQueryKeyRoot, props] as const
export type PostThreadParams = Pick< export type PostThreadParams = Pick<
@@ -89,7 +91,7 @@ export type ThreadItem =
| { | {
type: 'showHiddenReplies' type: 'showHiddenReplies'
key: string key: string
onPress: () => Promise<void> onPress: () => void
} }
| { | {
type: 'readMore' type: 'readMore'