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}
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}
/>
+85 -97
View File
@@ -47,11 +47,14 @@ export function usePostThread({anchor}: {anchor?: string}) {
view,
prioritizeFollowedUsers,
})
const postThreadHiddenQueryKey = createPostThreadHiddenQueryKey({
anchor,
prioritizeFollowedUsers,
})
const query = useQuery<UsePostThreadQueryResult>({
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,
+103 -77
View File
@@ -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<typeof createPostThreadQueryKey>
// TODO could clean this up?
params: PostThreadParams
postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
postThreadHiddenQueryKey: ReturnType<typeof createPostThreadHiddenQueryKey>
params: Pick<PostThreadParams, 'view'>
}) {
return {
insertReplies(
parentUri: string,
replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
) {
/*
* Main thread query mutator.
*/
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
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<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
@@ -123,7 +149,7 @@ export function createCacheMutator({
*/
deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
queryKey,
postThreadQueryKey,
queryData => {
if (!queryData) return
+9 -9
View File
@@ -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<void>
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,
})
}
}
+4 -2
View File
@@ -19,7 +19,9 @@ export const createPostThreadQueryKey = (props: PostThreadParams) =>
[postThreadQueryKeyRoot, props] as const
export const createPostThreadHiddenQueryKey = (
props: AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams,
props: Omit<AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams, 'anchor'> & {
anchor?: string
},
) => [postThreadHiddenQueryKeyRoot, props] as const
export type PostThreadParams = Pick<
@@ -89,7 +91,7 @@ export type ThreadItem =
| {
type: 'showHiddenReplies'
key: string
onPress: () => Promise<void>
onPress: () => void
}
| {
type: 'readMore'