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}
/> />
+85 -97
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({
*/ anchor: anchor!,
if (!hasServerHiddenItems) return prioritizeFollowedUsers,
}),
setServerHiddenThreadItems( )
Array.from({length: 2}).map((_, i) => return data
},
})
const serverHiddenThreadItems: ThreadItem[] = useMemo(() => {
if (!hiddenQueryEnabled) return []
if (hiddenQuery.isLoading) {
return Array.from({length: 2}).map((_, i) =>
views.skeleton({ views.skeleton({
key: `${anchor!}-reply-${i}`, key: `hidden-reply-${i}`,
item: 'reply', item: 'reply',
}), }),
), )
) } else if (hiddenQuery.isError) {
// TODO could insert error component
const params = { return []
anchor: anchor!, } else if (hiddenQuery.data?.thread) {
prioritizeFollowedUsers, const {threadItems} = sortAndAnnotateThreadItems(
} hiddenQuery.data.thread,
const data = await wait( {
400, view,
qc.fetchQuery({ skipHiddenReplyHandling: true,
queryKey: createPostThreadHiddenQueryKey(params), threadgateHiddenReplies: mergeThreadgateHiddenReplies(
async queryFn() { threadgate?.record,
const {data} = await agent.app.bsky.unspecced.getPostThreadHiddenV2( ),
params, moderationOpts: moderationOpts!,
)
return data.thread || []
}, },
}), )
) return threadItems
} else {
const {threadItems} = sortAndAnnotateThreadItems(data || [], { return []
view, }
skipHiddenReplyHandling: true,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
moderationOpts: moderationOpts!,
})
// insert the hidden replies into the state
setServerHiddenThreadItems(threadItems)
}, [ }, [
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,
+103 -77
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,96 +29,119 @@ 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
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
}),
)
}
}
return { return {
...queryData, ...data,
thread, thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
...data.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++) {
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 * 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) { 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'