Handle optimistic insert in hidden replies
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user