[Threads V2] Preliminary integration of unspecced V2 APIs (#8443)
* WIP * Sorting working * Rough handling of hidden/muted * Better muted/hidden sorting and handling * Clarify some naming * Fix parents * Handle first reply under highlighted/composer * WIP RaW * WIP optimistic * Optimistic WIP * Little cleanup, inserting dupes * Re-org * Add in new optimistic insert logic * Update types * Sorta working linear view optimistic state * Simple working version, no pref for OP * Working optimistic reply insertions, preference for OP * Ensure deletes are coming through * WIP scroll handling * WIP scroll tweaks * Clean up scrolling * Clean up onPostSuccess * Add annotations * Fix highlighted post calc * WIP kill me * Update APIs * Nvm don't kill me * Fix optimistic insert * Handle read more cases in tree view * Basically working read more * Handle linear view * Reorg * More reorg * Split up thread post components * New reply tree layout * Fix up traversal metadata * Tighten some spacing * Use indent ya idiot * Some linear mode cleanup * Fix lines on read more items * Vibe coding to success * Almost there with read mores * Update APIs * Bump sdk * Update import * Checkpoint new traversal * Checkpoint cleanup * Checkpoint, need to fix blocked posts * Checkpoint: think we're good, needs more cleanup * Clean it up * Two passes only * Set to default params, update comment * Fix render bug on native * Checkpoint parent rendering, can opt for slower handling here * Clean up parent handling, reply handling * Fix read more extra space * Fix read more in linear view * Fix hidden reply handling, seen count, before/after calc * Update naming * Rename Slice to ThreadItem * Add basic post and anchor skeletons * Refactor client-side hidden * WIP hidden fetching * Update types * Clean up query a bit * Scrolling still broken * Ok maybe fix scrolling * Checkpoint move state into meta query * Don't load remote hidden items unless needed * skeleton view * Reset hidden items when params change * Split up traversal and avoid multiple passes * Clean up * Checkpoint: handling exhausted replies * Clean up traversal functions further * Clean up pagination * Limit optimistic reply depth * Handle optimistic insert in hidden replies * Share root query key for easier cache extraction * Make blurred posts not look like ass * Fix double deleted item * Make optimistic deleted state not look like crap in tree view * Fix parents traversal 4 real * Rename tree post * Make optimistic deletions of linear posts not look bad * Rename linear post components * Handle tombstone views * Rename read more component * Add moreParents handling * Align interaction states of read more * Fix read more on FF * Tree view skeleton * Reply composer skele * Remove hack for showing more replies * Checkpoint: sort change scrolling fixed * Checkpoint: learned new things, reset to base * Feature gate * Rename * Replace show more * Update settings screen * Update pkg and endpoint * Remove console * Eureka * Cleanup last commit * No tests atm * Remove scroll provider * Clean up callbacks, better error state * Remove todo * Remove todo * Remove todos * Format * Ok I think scrolling is solid * Add back mobile compose input * Ok need to compute headerHeight every time * Update comments * Ok button up web too * Threads v2 tweaks (#8467) * fix error screen collapsing * use personx icon for blocked posts * Remove height/width * Revert unused Header change * Clarify code * Relate consts to theme values * Remove debug code * Typo * Fix debounce of threads prefs * Update metadata comments, dev mode * Missed a spot * Clean up todo * Fix up no-unauthenticated posts * Truncate parents if no-unauth * Update getBranch docs * Remove debug code * Expand fetching in some cases * Clear scroll need for root post to fix jump bug * Fix reply composer skeleton state * Remove uneeded initialized value * Add profile shadow cache * Some metrics * prettier tweak * eslint ignore * Fix optimistic insertion * Typo * Rename, comment * Remove wait * Counter naming * Replies seen counter for moderated sub-trees * Remove borders on skeleton * Align tombstone with optimistic deletion state * Fix optimistic deletion for thread * Add tree view icon * Rename * Cleanup * Update settings copy * Header menu open metric * Bump package * Better reply prompt (#8474) * restyle reply prompt * hide bottom bar border for cleaner look * use new border hiding hook in DMs * create `transparentifyColor` function * adjust padding * fix padding in immersive lpayer * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Integrate post-source (cherry picked from commit fe053e9b38395a4fcb30a4367bc800f64ea84fe9) --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com> Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import {type AppBskyUnspeccedGetPostThreadV2} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const LINEAR_VIEW_BELOW = 10
|
||||
|
||||
/**
|
||||
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const LINEAR_VIEW_BF = 1
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BELOW = 4
|
||||
|
||||
/**
|
||||
* See the `branchingFactor` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BF = undefined
|
||||
|
||||
/**
|
||||
* See the `below` param on {@link AppBskyUnspeccedGetPostThreadV2.QueryParams}
|
||||
*/
|
||||
export const TREE_VIEW_BELOW_DESKTOP = 6
|
||||
@@ -0,0 +1,325 @@
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {
|
||||
LINEAR_VIEW_BELOW,
|
||||
LINEAR_VIEW_BF,
|
||||
TREE_VIEW_BELOW,
|
||||
TREE_VIEW_BELOW_DESKTOP,
|
||||
TREE_VIEW_BF,
|
||||
} from '#/state/queries/usePostThread/const'
|
||||
import {
|
||||
createCacheMutator,
|
||||
getThreadPlaceholder,
|
||||
} from '#/state/queries/usePostThread/queryCache'
|
||||
import {
|
||||
buildThread,
|
||||
sortAndAnnotateThreadItems,
|
||||
} from '#/state/queries/usePostThread/traversal'
|
||||
import {
|
||||
createPostThreadOtherQueryKey,
|
||||
createPostThreadQueryKey,
|
||||
type ThreadItem,
|
||||
type UsePostThreadQueryResult,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {getThreadgateRecord} from '#/state/queries/usePostThread/utils'
|
||||
import * as views from '#/state/queries/usePostThread/views'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
export * from '#/state/queries/usePostThread/types'
|
||||
|
||||
export function usePostThread({anchor}: {anchor?: string}) {
|
||||
const qc = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
|
||||
const {
|
||||
isLoaded: isThreadPreferencesLoaded,
|
||||
sort,
|
||||
setSort: baseSetSort,
|
||||
view,
|
||||
setView: baseSetView,
|
||||
prioritizeFollowedUsers,
|
||||
} = useThreadPreferences()
|
||||
const below = useMemo(() => {
|
||||
return view === 'linear'
|
||||
? LINEAR_VIEW_BELOW
|
||||
: isWeb && gtPhone
|
||||
? TREE_VIEW_BELOW_DESKTOP
|
||||
: TREE_VIEW_BELOW
|
||||
}, [view, gtPhone])
|
||||
|
||||
const postThreadQueryKey = createPostThreadQueryKey({
|
||||
anchor,
|
||||
sort,
|
||||
view,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
const postThreadOtherQueryKey = createPostThreadOtherQueryKey({
|
||||
anchor,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
|
||||
const query = useQuery<UsePostThreadQueryResult>({
|
||||
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
|
||||
queryKey: postThreadQueryKey,
|
||||
async queryFn(ctx) {
|
||||
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
|
||||
anchor: anchor!,
|
||||
branchingFactor: view === 'linear' ? LINEAR_VIEW_BF : TREE_VIEW_BF,
|
||||
below,
|
||||
sort: sort,
|
||||
prioritizeFollowedUsers: prioritizeFollowedUsers,
|
||||
})
|
||||
|
||||
/*
|
||||
* Initialize `ctx.meta` to track if we know we have additional replies
|
||||
* we could fetch once we hit the end.
|
||||
*/
|
||||
ctx.meta = ctx.meta || {
|
||||
hasOtherReplies: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* If we know we have additional replies, we'll set this to true.
|
||||
*/
|
||||
if (data.hasOtherReplies) {
|
||||
ctx.meta.hasOtherReplies = true
|
||||
}
|
||||
|
||||
const result = {
|
||||
thread: data.thread || [],
|
||||
threadgate: data.threadgate,
|
||||
hasOtherReplies: !!ctx.meta.hasOtherReplies,
|
||||
}
|
||||
|
||||
const record = getThreadgateRecord(result.threadgate)
|
||||
if (result.threadgate && record) {
|
||||
result.threadgate.record = record
|
||||
}
|
||||
|
||||
return result as UsePostThreadQueryResult
|
||||
},
|
||||
placeholderData() {
|
||||
if (!anchor) return
|
||||
const placeholder = getThreadPlaceholder(qc, anchor)
|
||||
/*
|
||||
* Always return something here, even empty data, so that
|
||||
* `isPlaceholderData` is always true, which we'll use to insert
|
||||
* skeletons.
|
||||
*/
|
||||
const thread = placeholder ? [placeholder] : []
|
||||
return {thread, threadgate: undefined, hasOtherReplies: false}
|
||||
},
|
||||
select(data) {
|
||||
const record = getThreadgateRecord(data.threadgate)
|
||||
if (data.threadgate && record) {
|
||||
data.threadgate.record = record
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const thread = useMemo(() => query.data?.thread || [], [query.data?.thread])
|
||||
const threadgate = useMemo(
|
||||
() => query.data?.threadgate,
|
||||
[query.data?.threadgate],
|
||||
)
|
||||
const hasOtherThreadItems = useMemo(
|
||||
() => !!query.data?.hasOtherReplies,
|
||||
[query.data?.hasOtherReplies],
|
||||
)
|
||||
const [otherItemsVisible, setOtherItemsVisible] = useState(false)
|
||||
|
||||
/**
|
||||
* Creates a mutator for the post thread cache. This is used to insert
|
||||
* replies into the thread cache after posting.
|
||||
*/
|
||||
const mutator = useMemo(
|
||||
() =>
|
||||
createCacheMutator({
|
||||
params: {view, below},
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
queryClient: qc,
|
||||
}),
|
||||
[qc, view, below, postThreadQueryKey, postThreadOtherQueryKey],
|
||||
)
|
||||
|
||||
/**
|
||||
* If we have additional items available from the server and the user has
|
||||
* chosen to view them, start loading data
|
||||
*/
|
||||
const additionalQueryEnabled = hasOtherThreadItems && otherItemsVisible
|
||||
const additionalItemsQuery = useQuery({
|
||||
enabled: additionalQueryEnabled,
|
||||
queryKey: postThreadOtherQueryKey,
|
||||
async queryFn() {
|
||||
const {data} = await agent.app.bsky.unspecced.getPostThreadOtherV2({
|
||||
anchor: anchor!,
|
||||
prioritizeFollowedUsers,
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
const serverOtherThreadItems: ThreadItem[] = useMemo(() => {
|
||||
if (!additionalQueryEnabled) return []
|
||||
if (additionalItemsQuery.isLoading) {
|
||||
return Array.from({length: 2}).map((_, i) =>
|
||||
views.skeleton({
|
||||
key: `other-reply-${i}`,
|
||||
item: 'reply',
|
||||
}),
|
||||
)
|
||||
} else if (additionalItemsQuery.isError) {
|
||||
/*
|
||||
* We could insert an special error component in here, but since these
|
||||
* are optional additional replies, it's not critical that they're shown
|
||||
* atm.
|
||||
*/
|
||||
return []
|
||||
} else if (additionalItemsQuery.data?.thread) {
|
||||
const {threadItems} = sortAndAnnotateThreadItems(
|
||||
additionalItemsQuery.data.thread,
|
||||
{
|
||||
view,
|
||||
skipModerationHandling: true,
|
||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
|
||||
threadgate?.record,
|
||||
),
|
||||
moderationOpts: moderationOpts!,
|
||||
},
|
||||
)
|
||||
return threadItems
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}, [
|
||||
view,
|
||||
additionalQueryEnabled,
|
||||
additionalItemsQuery,
|
||||
mergeThreadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
threadgate?.record,
|
||||
])
|
||||
|
||||
/**
|
||||
* Sets the sort order for the thread and resets the additional thread items
|
||||
*/
|
||||
const setSort: typeof baseSetSort = useCallback(
|
||||
nextSort => {
|
||||
setOtherItemsVisible(false)
|
||||
baseSetSort(nextSort)
|
||||
},
|
||||
[baseSetSort, setOtherItemsVisible],
|
||||
)
|
||||
|
||||
/**
|
||||
* Sets the view variant for the thread and resets the additional thread items
|
||||
*/
|
||||
const setView: typeof baseSetView = useCallback(
|
||||
nextView => {
|
||||
setOtherItemsVisible(false)
|
||||
baseSetView(nextView)
|
||||
},
|
||||
[baseSetView, setOtherItemsVisible],
|
||||
)
|
||||
|
||||
/*
|
||||
* This is the main thread response, sorted into separate buckets based on
|
||||
* moderation, and annotated with all UI state needed for rendering.
|
||||
*/
|
||||
const {threadItems, otherThreadItems} = useMemo(() => {
|
||||
return sortAndAnnotateThreadItems(thread, {
|
||||
view: view,
|
||||
threadgateHiddenReplies: mergeThreadgateHiddenReplies(threadgate?.record),
|
||||
moderationOpts: moderationOpts!,
|
||||
})
|
||||
}, [
|
||||
thread,
|
||||
threadgate?.record,
|
||||
mergeThreadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
view,
|
||||
])
|
||||
|
||||
/*
|
||||
* Take all three sets of thread items and combine them into a single thread,
|
||||
* along with any other thread items required for rendering e.g. "Show more
|
||||
* replies" or the reply composer.
|
||||
*/
|
||||
const items = useMemo(() => {
|
||||
return buildThread({
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
isLoading: query.isPlaceholderData,
|
||||
hasSession,
|
||||
hasOtherThreadItems,
|
||||
otherItemsVisible,
|
||||
showOtherItems: () => setOtherItemsVisible(true),
|
||||
})
|
||||
}, [
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
query.isPlaceholderData,
|
||||
hasSession,
|
||||
hasOtherThreadItems,
|
||||
otherItemsVisible,
|
||||
setOtherItemsVisible,
|
||||
])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state: {
|
||||
/*
|
||||
* Copy in any query state that is useful
|
||||
*/
|
||||
isFetching: query.isFetching,
|
||||
isPlaceholderData: query.isPlaceholderData,
|
||||
error: query.error,
|
||||
/*
|
||||
* Other state
|
||||
*/
|
||||
sort,
|
||||
view,
|
||||
otherItemsVisible,
|
||||
},
|
||||
data: {
|
||||
items,
|
||||
threadgate,
|
||||
},
|
||||
actions: {
|
||||
/*
|
||||
* Copy in any query actions that are useful
|
||||
*/
|
||||
insertReplies: mutator.insertReplies,
|
||||
refetch: query.refetch,
|
||||
/*
|
||||
* Other actions
|
||||
*/
|
||||
setSort,
|
||||
setView,
|
||||
},
|
||||
}),
|
||||
[
|
||||
query,
|
||||
mutator.insertReplies,
|
||||
otherItemsVisible,
|
||||
sort,
|
||||
view,
|
||||
setSort,
|
||||
setView,
|
||||
threadgate,
|
||||
items,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadOtherV2,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {type QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||
import {getBranch} from '#/state/queries/usePostThread/traversal'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type createPostThreadOtherQueryKey,
|
||||
type createPostThreadQueryKey,
|
||||
type PostThreadParams,
|
||||
postThreadQueryKeyRoot,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {getRootPostAtUri} from '#/state/queries/usePostThread/utils'
|
||||
import {postViewToThreadPlaceholder} from '#/state/queries/usePostThread/views'
|
||||
import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
|
||||
import {embedViewRecordToPostView} from '#/state/queries/util'
|
||||
|
||||
export function createCacheMutator({
|
||||
queryClient,
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
params,
|
||||
}: {
|
||||
queryClient: QueryClient
|
||||
postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
|
||||
postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
|
||||
params: Pick<PostThreadParams, 'view'> & {below: number}
|
||||
}) {
|
||||
return {
|
||||
insertReplies(
|
||||
parentUri: string,
|
||||
replies: AppBskyUnspeccedGetPostThreadV2.ThreadItem[],
|
||||
) {
|
||||
/*
|
||||
* Main thread query mutator.
|
||||
*/
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
|
||||
postThreadQueryKey,
|
||||
data => {
|
||||
if (!data) return
|
||||
return {
|
||||
...data,
|
||||
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
|
||||
...data.thread,
|
||||
]),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* Additional replies query mutator.
|
||||
*/
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadOtherV2.OutputSchema>(
|
||||
postThreadOtherQueryKey,
|
||||
data => {
|
||||
if (!data) return
|
||||
return {
|
||||
...data,
|
||||
thread: mutator<AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem>([
|
||||
...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
|
||||
const itemsToInsert = 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 <= params.below
|
||||
})
|
||||
|
||||
thread.splice(i + 1, itemsToRemove, ...itemsToInsert)
|
||||
}
|
||||
}
|
||||
|
||||
return thread as T[]
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Unused atm, post shadow does the trick, but it would be nice to clean up
|
||||
* the whole sub-tree on deletes.
|
||||
*/
|
||||
deletePost(post: AppBskyUnspeccedGetPostThreadV2.ThreadItem) {
|
||||
queryClient.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
|
||||
postThreadQueryKey,
|
||||
queryData => {
|
||||
if (!queryData) return
|
||||
|
||||
const thread = [...queryData.thread]
|
||||
|
||||
for (let i = 0; i < thread.length; i++) {
|
||||
const existingPost = thread[i]
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(post.value)) continue
|
||||
|
||||
if (existingPost.uri === post.uri) {
|
||||
const branch = getBranch(thread, i, existingPost.depth)
|
||||
thread.splice(branch.start, branch.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
thread,
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPlaceholder(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): $Typed<AppBskyUnspeccedGetPostThreadV2.ThreadItem> | void {
|
||||
let partial
|
||||
for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
|
||||
/*
|
||||
* Currently, the backend doesn't send full post info in some cases (for
|
||||
* example, for quoted posts). We use missing `likeCount` as a way to
|
||||
* detect that. In the future, we should fix this on the backend, which
|
||||
* will let us always stop on the first result.
|
||||
*
|
||||
* TODO can we send in feeds and quotes?
|
||||
*/
|
||||
const hasAllInfo = item.value.post.likeCount != null
|
||||
if (hasAllInfo) {
|
||||
return item
|
||||
} else {
|
||||
// Keep searching, we might still find a full post in the cache.
|
||||
partial = item
|
||||
}
|
||||
}
|
||||
return partial
|
||||
}
|
||||
|
||||
export function* getThreadPlaceholderCandidates(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<
|
||||
$Typed<
|
||||
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
}
|
||||
>,
|
||||
void
|
||||
> {
|
||||
/*
|
||||
* Check post thread queries first
|
||||
*/
|
||||
for (const post of findAllPostsInQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
|
||||
/*
|
||||
* Check notifications first. If you have a post in notifications, it's
|
||||
* often due to a like or a repost, and we want to prioritize a post object
|
||||
* with >0 likes/reposts over a stale version with no metrics in order to
|
||||
* avoid a notification->post scroll jump.
|
||||
*/
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
||||
queryClient,
|
||||
uri,
|
||||
)) {
|
||||
yield postViewToThreadPlaceholder(post)
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<AppBskyFeedDefs.PostView, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
||||
queryKey: [postThreadQueryKeyRoot],
|
||||
})
|
||||
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) continue
|
||||
|
||||
const {thread} = queryData
|
||||
|
||||
for (const item of thread) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (didOrHandleUriMatches(atUri, item.value.post)) {
|
||||
yield item.value.post
|
||||
}
|
||||
|
||||
const qp = getEmbeddedPost(item.value.post.embed)
|
||||
if (qp && didOrHandleUriMatches(atUri, qp)) {
|
||||
yield embedViewRecordToPostView(qp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
|
||||
const queryDatas =
|
||||
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
|
||||
queryKey: [postThreadQueryKeyRoot],
|
||||
})
|
||||
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) continue
|
||||
|
||||
const {thread} = queryData
|
||||
|
||||
for (const item of thread) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (item.value.post.author.did === did) {
|
||||
yield item.value.post.author
|
||||
}
|
||||
|
||||
const qp = getEmbeddedPost(item.value.post.embed)
|
||||
if (qp && qp.author.did === did) {
|
||||
yield qp.author
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
/* eslint-disable no-labels */
|
||||
import {AppBskyUnspeccedDefs, type ModerationOpts} from '@atproto/api'
|
||||
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type PostThreadParams,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {
|
||||
getPostRecord,
|
||||
getThreadPostNoUnauthenticatedUI,
|
||||
getThreadPostUI,
|
||||
getTraversalMetadata,
|
||||
storeTraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/utils'
|
||||
import * as views from '#/state/queries/usePostThread/views'
|
||||
|
||||
export function sortAndAnnotateThreadItems(
|
||||
thread: ApiThreadItem[],
|
||||
{
|
||||
threadgateHiddenReplies,
|
||||
moderationOpts,
|
||||
view,
|
||||
skipModerationHandling,
|
||||
}: {
|
||||
threadgateHiddenReplies: Set<string>
|
||||
moderationOpts: ModerationOpts
|
||||
view: PostThreadParams['view']
|
||||
/**
|
||||
* Set to `true` in cases where we already know the moderation state of the
|
||||
* post e.g. when fetching additional replies from the server. This will
|
||||
* prevent additional sorting or nested-branch truncation, and all replies,
|
||||
* regardless of moderation state, will be included in the resulting
|
||||
* `threadItems` array.
|
||||
*/
|
||||
skipModerationHandling?: boolean
|
||||
},
|
||||
) {
|
||||
const threadItems: ThreadItem[] = []
|
||||
const otherThreadItems: ThreadItem[] = []
|
||||
const metadatas = new Map<string, TraversalMetadata>()
|
||||
|
||||
traversal: for (let i = 0; i < thread.length; i++) {
|
||||
const item = thread[i]
|
||||
let parentMetadata: TraversalMetadata | undefined
|
||||
let metadata: TraversalMetadata | undefined
|
||||
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
parentMetadata = metadatas.get(
|
||||
getPostRecord(item.value.post).reply?.parent?.uri || '',
|
||||
)
|
||||
metadata = getTraversalMetadata({
|
||||
item,
|
||||
parentMetadata,
|
||||
prevItem: thread.at(i - 1),
|
||||
nextItem: thread.at(i + 1),
|
||||
})
|
||||
storeTraversalMetadata(metadatas, metadata)
|
||||
}
|
||||
|
||||
if (item.depth < 0) {
|
||||
/*
|
||||
* Parents are ignored until we find the anchor post, then we walk
|
||||
* _up_ from there.
|
||||
*/
|
||||
} else if (item.depth === 0) {
|
||||
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value)) {
|
||||
threadItems.push(views.threadPostNoUnauthenticated(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item.value)) {
|
||||
threadItems.push(views.threadPostNotFound(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)) {
|
||||
threadItems.push(views.threadPostBlocked(item))
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
const post = views.threadPost({
|
||||
uri: item.uri,
|
||||
depth: item.depth,
|
||||
value: item.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
threadItems.push(post)
|
||||
|
||||
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
|
||||
const parent = thread[pi]
|
||||
|
||||
if (
|
||||
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent.value)
|
||||
) {
|
||||
const post = views.threadPostNoUnauthenticated(parent)
|
||||
post.ui = getThreadPostNoUnauthenticatedUI({
|
||||
depth: parent.depth,
|
||||
// ignore for now
|
||||
// prevItemDepth: thread[pi - 1]?.depth,
|
||||
nextItemDepth: thread[pi + 1]?.depth,
|
||||
})
|
||||
threadItems.unshift(post)
|
||||
// for now, break parent traversal at first no-unauthed
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent.value)) {
|
||||
threadItems.unshift(views.threadPostNotFound(parent))
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent.value)) {
|
||||
threadItems.unshift(views.threadPostBlocked(parent))
|
||||
break parentTraversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent.value)) {
|
||||
threadItems.unshift(
|
||||
views.threadPost({
|
||||
uri: parent.uri,
|
||||
depth: parent.depth,
|
||||
value: parent.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.depth > 0) {
|
||||
/*
|
||||
* The API does not send down any unavailable replies, so this will
|
||||
* always be false (for now). If we ever wanted to tombstone them here,
|
||||
* we could.
|
||||
*/
|
||||
const shouldBreak =
|
||||
AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item.value) ||
|
||||
AppBskyUnspeccedDefs.isThreadItemNotFound(item.value) ||
|
||||
AppBskyUnspeccedDefs.isThreadItemBlocked(item.value)
|
||||
|
||||
if (shouldBreak) {
|
||||
const branch = getBranch(thread, i, item.depth)
|
||||
// could insert tombstone
|
||||
i = branch.end
|
||||
continue traversal
|
||||
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
if (parentMetadata) {
|
||||
/*
|
||||
* Set this value before incrementing the parent's repliesSeenCounter
|
||||
*/
|
||||
metadata!.replyIndex = parentMetadata.repliesIndexCounter
|
||||
// Increment the parent's repliesIndexCounter
|
||||
parentMetadata.repliesIndexCounter += 1
|
||||
}
|
||||
|
||||
const post = views.threadPost({
|
||||
uri: item.uri,
|
||||
depth: item.depth,
|
||||
value: item.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
|
||||
if (!post.isBlurred || skipModerationHandling) {
|
||||
/*
|
||||
* Not moderated, need to insert it
|
||||
*/
|
||||
threadItems.push(post)
|
||||
|
||||
/*
|
||||
* Update seen reply count of parent
|
||||
*/
|
||||
if (parentMetadata) {
|
||||
parentMetadata.repliesSeenCounter += 1
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
* Moderated in some way, we're going to walk children
|
||||
*/
|
||||
const parent = post
|
||||
const parentIsTopLevelReply = parent.depth === 1
|
||||
// get sub tree
|
||||
const branch = getBranch(thread, i, item.depth)
|
||||
|
||||
if (parentIsTopLevelReply) {
|
||||
// push branch anchor into sorted array
|
||||
otherThreadItems.push(parent)
|
||||
// skip branch anchor in branch traversal
|
||||
const startIndex = branch.start + 1
|
||||
|
||||
for (let ci = startIndex; ci <= branch.end; ci++) {
|
||||
const child = thread[ci]
|
||||
|
||||
if (AppBskyUnspeccedDefs.isThreadItemPost(child.value)) {
|
||||
const childParentMetadata = metadatas.get(
|
||||
getPostRecord(child.value.post).reply?.parent?.uri || '',
|
||||
)
|
||||
const childMetadata = getTraversalMetadata({
|
||||
item: child,
|
||||
prevItem: thread[ci - 1],
|
||||
nextItem: thread[ci + 1],
|
||||
parentMetadata: childParentMetadata,
|
||||
})
|
||||
storeTraversalMetadata(metadatas, childMetadata)
|
||||
if (childParentMetadata) {
|
||||
/*
|
||||
* Set this value before incrementing the parent's repliesIndexCounter
|
||||
*/
|
||||
childMetadata!.replyIndex =
|
||||
childParentMetadata.repliesIndexCounter
|
||||
childParentMetadata.repliesIndexCounter += 1
|
||||
}
|
||||
|
||||
const childPost = views.threadPost({
|
||||
uri: child.uri,
|
||||
depth: child.depth,
|
||||
value: child.value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
})
|
||||
|
||||
/*
|
||||
* If a child is moderated in any way, drop it an its sub-branch
|
||||
* entirely. To reveal these, the user must navigate to the
|
||||
* parent post directly.
|
||||
*/
|
||||
if (childPost.isBlurred) {
|
||||
ci = getBranch(thread, ci, child.depth).end
|
||||
} else {
|
||||
otherThreadItems.push(childPost)
|
||||
|
||||
if (childParentMetadata) {
|
||||
childParentMetadata.repliesSeenCounter += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/*
|
||||
* Drop the rest of the branch if we hit anything unexpected
|
||||
*/
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip to next branch
|
||||
*/
|
||||
i = branch.end
|
||||
continue traversal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Both `threadItems` and `otherThreadItems` now need to be traversed again to fully compute
|
||||
* UI state based on collected metadata. These arrays will be muted in situ.
|
||||
*/
|
||||
for (const subset of [threadItems, otherThreadItems]) {
|
||||
for (let i = 0; i < subset.length; i++) {
|
||||
const item = subset[i]
|
||||
const prevItem = subset.at(i - 1)
|
||||
const nextItem = subset.at(i + 1)
|
||||
|
||||
if (item.type === 'threadPost') {
|
||||
const metadata = metadatas.get(item.uri)
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.parentMetadata) {
|
||||
/*
|
||||
* Track what's before/after now that we've applied moderation
|
||||
*/
|
||||
if (prevItem?.type === 'threadPost')
|
||||
metadata.prevItemDepth = prevItem?.depth
|
||||
if (nextItem?.type === 'threadPost')
|
||||
metadata.nextItemDepth = nextItem?.depth
|
||||
|
||||
/*
|
||||
* We can now officially calculate `isLastSibling` and `isLastChild`
|
||||
* based on the actual data that we've seen.
|
||||
*/
|
||||
metadata.isLastSibling =
|
||||
metadata.replyIndex ===
|
||||
metadata.parentMetadata.repliesSeenCounter - 1
|
||||
metadata.isLastChild =
|
||||
metadata.nextItemDepth === undefined ||
|
||||
metadata.nextItemDepth <= metadata.depth
|
||||
|
||||
/*
|
||||
* If this is the last sibling, it's implicitly part of the last
|
||||
* branch of this sub-tree.
|
||||
*/
|
||||
if (metadata.isLastSibling) {
|
||||
metadata.isPartOfLastBranchFromDepth = metadata.depth
|
||||
|
||||
/**
|
||||
* If the parent is part of the last branch of the sub-tree, so is the child.
|
||||
*/
|
||||
if (metadata.parentMetadata.isPartOfLastBranchFromDepth) {
|
||||
metadata.isPartOfLastBranchFromDepth =
|
||||
metadata.parentMetadata.isPartOfLastBranchFromDepth
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* If this is the last sibling, and the parent has unhydrated replies,
|
||||
* at some point down the line we will need to show a "read more".
|
||||
*/
|
||||
if (
|
||||
metadata.parentMetadata.repliesUnhydrated > 0 &&
|
||||
metadata.isLastSibling
|
||||
) {
|
||||
metadata.upcomingParentReadMore = metadata.parentMetadata
|
||||
}
|
||||
|
||||
/*
|
||||
* Copy in the parent's upcoming read more, if it exists. Once we
|
||||
* reach the bottom, we'll insert a "read more"
|
||||
*/
|
||||
if (metadata.parentMetadata.upcomingParentReadMore) {
|
||||
metadata.upcomingParentReadMore =
|
||||
metadata.parentMetadata.upcomingParentReadMore
|
||||
}
|
||||
|
||||
/*
|
||||
* Copy in the parent's skipped indents
|
||||
*/
|
||||
metadata.skippedIndentIndices = new Set([
|
||||
...metadata.parentMetadata.skippedIndentIndices,
|
||||
])
|
||||
|
||||
/**
|
||||
* If this is the last sibling, and the parent has no unhydrated
|
||||
* replies, then we know we can skip an indent line.
|
||||
*/
|
||||
if (
|
||||
metadata.parentMetadata.repliesUnhydrated <= 0 &&
|
||||
metadata.isLastSibling
|
||||
) {
|
||||
/**
|
||||
* Depth is 2 more than the 0-index of the indent calculation
|
||||
* bc of how we render these. So instead of handling that in the
|
||||
* component, we just adjust that back to 0-index here.
|
||||
*/
|
||||
metadata.skippedIndentIndices.add(item.depth - 2)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* If this post has unhydrated replies, and it is the last child, then
|
||||
* it itself needs a "read more"
|
||||
*/
|
||||
if (metadata.repliesUnhydrated > 0 && metadata.isLastChild) {
|
||||
metadata.precedesChildReadMore = true
|
||||
subset.splice(i + 1, 0, views.readMore(metadata))
|
||||
i++ // skip next iteration
|
||||
}
|
||||
|
||||
/*
|
||||
* Tree-view only.
|
||||
*
|
||||
* If there's an upcoming parent read more, this branch is part of the
|
||||
* last branch of the sub-tree, and the item itself is the last child,
|
||||
* insert the parent "read more".
|
||||
*/
|
||||
if (
|
||||
view === 'tree' &&
|
||||
metadata.upcomingParentReadMore &&
|
||||
metadata.isPartOfLastBranchFromDepth ===
|
||||
metadata.upcomingParentReadMore.depth &&
|
||||
metadata.isLastChild
|
||||
) {
|
||||
subset.splice(
|
||||
i + 1,
|
||||
0,
|
||||
views.readMore(metadata.upcomingParentReadMore),
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
/**
|
||||
* Only occurs for the first item in the thread, which may have
|
||||
* additional parents not included in this request.
|
||||
*/
|
||||
if (item.value.moreParents) {
|
||||
metadata.followsReadMoreUp = true
|
||||
subset.splice(i, 0, views.readMoreUp(metadata))
|
||||
i++
|
||||
}
|
||||
|
||||
/*
|
||||
* Calculate the final UI state for the thread item.
|
||||
*/
|
||||
item.ui = getThreadPostUI(metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildThread({
|
||||
threadItems,
|
||||
otherThreadItems,
|
||||
serverOtherThreadItems,
|
||||
isLoading,
|
||||
hasSession,
|
||||
otherItemsVisible,
|
||||
hasOtherThreadItems,
|
||||
showOtherItems,
|
||||
}: {
|
||||
threadItems: ThreadItem[]
|
||||
otherThreadItems: ThreadItem[]
|
||||
serverOtherThreadItems: ThreadItem[]
|
||||
isLoading: boolean
|
||||
hasSession: boolean
|
||||
otherItemsVisible: boolean
|
||||
hasOtherThreadItems: boolean
|
||||
showOtherItems: () => void
|
||||
}) {
|
||||
/**
|
||||
* `threadItems` is memoized here, so don't mutate it directly.
|
||||
*/
|
||||
const items = [...threadItems]
|
||||
|
||||
if (isLoading) {
|
||||
const anchorPost = items.at(0)
|
||||
const hasAnchorFromCache = anchorPost && anchorPost.type === 'threadPost'
|
||||
const skeletonReplies = hasAnchorFromCache
|
||||
? anchorPost.value.post.replyCount ?? 4
|
||||
: 4
|
||||
|
||||
if (!items.length) {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: 'anchor-skeleton',
|
||||
item: 'anchor',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSession) {
|
||||
// we might have this from cache
|
||||
const replyDisabled =
|
||||
hasAnchorFromCache &&
|
||||
anchorPost.value.post.viewer?.replyDisabled === true
|
||||
|
||||
if (hasAnchorFromCache) {
|
||||
if (!replyDisabled) {
|
||||
items.push({
|
||||
type: 'replyComposer',
|
||||
key: 'replyComposer',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: 'replyComposer',
|
||||
item: 'replyComposer',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < skeletonReplies; i++) {
|
||||
items.push(
|
||||
views.skeleton({
|
||||
key: `anchor-skeleton-reply-${i}`,
|
||||
item: 'reply',
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (
|
||||
item.type === 'threadPost' &&
|
||||
item.depth === 0 &&
|
||||
!item.value.post.viewer?.replyDisabled &&
|
||||
hasSession
|
||||
) {
|
||||
items.splice(i + 1, 0, {
|
||||
type: 'replyComposer',
|
||||
key: 'replyComposer',
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (otherThreadItems.length || hasOtherThreadItems) {
|
||||
if (otherItemsVisible) {
|
||||
items.push(...otherThreadItems)
|
||||
items.push(...serverOtherThreadItems)
|
||||
} else {
|
||||
items.push({
|
||||
type: 'showOtherReplies',
|
||||
key: 'showOtherReplies',
|
||||
onPress: showOtherItems,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start and end index of a "branch" of the thread. A "branch" is a
|
||||
* parent and it's children (not siblings). Returned indices are inclusive of
|
||||
* the parent and its last child.
|
||||
*
|
||||
* items[] (index, depth)
|
||||
* └─┬ anchor ──────── (0, 0)
|
||||
* ├─── branch ───── (1, 1)
|
||||
* ├──┬ branch ───── (2, 1) (start)
|
||||
* │ ├──┬ leaf ──── (3, 2)
|
||||
* │ │ └── leaf ── (4, 3)
|
||||
* │ └─── leaf ──── (5, 2) (end)
|
||||
* ├─── branch ───── (6, 1)
|
||||
* └─── branch ───── (7, 1)
|
||||
*
|
||||
* const { start: 2, end: 5, length: 3 } = getBranch(items, 2, 1)
|
||||
*/
|
||||
export function getBranch(
|
||||
thread: ApiThreadItem[],
|
||||
branchStartIndex: number,
|
||||
branchStartDepth: number,
|
||||
) {
|
||||
let end = branchStartIndex
|
||||
|
||||
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
|
||||
const next = thread[ci]
|
||||
if (next.depth > branchStartDepth) {
|
||||
end = ci
|
||||
} else {
|
||||
end = ci - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: branchStartIndex,
|
||||
end,
|
||||
length: end - branchStartIndex,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyFeedThreadgate,
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadOtherV2,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
|
||||
export type ApiThreadItem =
|
||||
| AppBskyUnspeccedGetPostThreadV2.ThreadItem
|
||||
| AppBskyUnspeccedGetPostThreadOtherV2.ThreadItem
|
||||
|
||||
export const postThreadQueryKeyRoot = 'post-thread-v2' as const
|
||||
|
||||
export const createPostThreadQueryKey = (props: PostThreadParams) =>
|
||||
[postThreadQueryKeyRoot, props] as const
|
||||
|
||||
export const createPostThreadOtherQueryKey = (
|
||||
props: Omit<AppBskyUnspeccedGetPostThreadOtherV2.QueryParams, 'anchor'> & {
|
||||
anchor?: string
|
||||
},
|
||||
) => [postThreadQueryKeyRoot, 'other', props] as const
|
||||
|
||||
export type PostThreadParams = Pick<
|
||||
AppBskyUnspeccedGetPostThreadV2.QueryParams,
|
||||
'sort' | 'prioritizeFollowedUsers'
|
||||
> & {
|
||||
anchor?: string
|
||||
view: 'tree' | 'linear'
|
||||
}
|
||||
|
||||
export type UsePostThreadQueryResult = {
|
||||
hasOtherReplies: boolean
|
||||
thread: AppBskyUnspeccedGetPostThreadV2.ThreadItem[]
|
||||
threadgate?: Omit<AppBskyFeedDefs.ThreadgateView, 'record'> & {
|
||||
record: AppBskyFeedThreadgate.Record
|
||||
}
|
||||
}
|
||||
|
||||
export type ThreadItem =
|
||||
| {
|
||||
type: 'threadPost'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
|
||||
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
||||
record: AppBskyFeedPost.Record
|
||||
}
|
||||
}
|
||||
isBlurred: boolean
|
||||
moderation: ModerationDecision
|
||||
ui: {
|
||||
isAnchor: boolean
|
||||
showParentReplyLine: boolean
|
||||
showChildReplyLine: boolean
|
||||
indent: number
|
||||
isLastChild: boolean
|
||||
skippedIndentIndices: Set<number>
|
||||
precedesChildReadMore: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'threadPostNoUnauthenticated'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
|
||||
ui: {
|
||||
showParentReplyLine: boolean
|
||||
showChildReplyLine: boolean
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'threadPostNotFound'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemNotFound
|
||||
}
|
||||
| {
|
||||
type: 'threadPostBlocked'
|
||||
key: string
|
||||
uri: string
|
||||
depth: number
|
||||
value: AppBskyUnspeccedDefs.ThreadItemBlocked
|
||||
}
|
||||
| {
|
||||
type: 'replyComposer'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'showOtherReplies'
|
||||
key: string
|
||||
onPress: () => void
|
||||
}
|
||||
| {
|
||||
/*
|
||||
* Read more replies, downwards in the thread.
|
||||
*/
|
||||
type: 'readMore'
|
||||
key: string
|
||||
depth: number
|
||||
href: string
|
||||
moreReplies: number
|
||||
skippedIndentIndices: Set<number>
|
||||
}
|
||||
| {
|
||||
/*
|
||||
* Read more parents, upwards in the thread.
|
||||
*/
|
||||
type: 'readMoreUp'
|
||||
key: string
|
||||
href: string
|
||||
}
|
||||
| {
|
||||
type: 'skeleton'
|
||||
key: string
|
||||
item: 'anchor' | 'reply' | 'replyComposer'
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata collected while traversing the raw data from the thread response.
|
||||
* Some values here can be computed immediately, while others need to be
|
||||
* computed during a second pass over the thread after we know things like
|
||||
* total number of replies, the reply index, etc.
|
||||
*
|
||||
* The idea here is that these values should be objectively true in all cases,
|
||||
* such that we can use them later — either individually on in composite — to
|
||||
* drive rendering behaviors.
|
||||
*/
|
||||
export type TraversalMetadata = {
|
||||
/**
|
||||
* The depth of the post in the reply tree, where 0 is the root post. This is
|
||||
* calculated on the server.
|
||||
*/
|
||||
depth: number
|
||||
/**
|
||||
* Indicates if this item is a "read more" link preceding this post that
|
||||
* continues the thread upwards.
|
||||
*/
|
||||
followsReadMoreUp: boolean
|
||||
/**
|
||||
* Indicates if the post is the last reply beneath its parent post.
|
||||
*/
|
||||
isLastSibling: boolean
|
||||
/**
|
||||
* Indicates the post is the end-of-the-line for a given branch of replies.
|
||||
*/
|
||||
isLastChild: boolean
|
||||
/**
|
||||
* Indicates if the post is the left/lower-most branch of the reply tree.
|
||||
* Value corresponds to the depth at which this branch started.
|
||||
*/
|
||||
isPartOfLastBranchFromDepth?: number
|
||||
/**
|
||||
* The depth of the slice immediately following this one, if it exists.
|
||||
*/
|
||||
nextItemDepth?: number
|
||||
/**
|
||||
* This is a live reference to the parent metadata object. Mutations to this
|
||||
* are available for later use in children.
|
||||
*/
|
||||
parentMetadata?: TraversalMetadata
|
||||
/**
|
||||
* Populated during the final traversal of the thread. Denotes whether
|
||||
* there is a "Read more" link for this item immediately following
|
||||
* this item.
|
||||
*/
|
||||
precedesChildReadMore: boolean
|
||||
/**
|
||||
* The depth of the slice immediately preceding this one, if it exists.
|
||||
*/
|
||||
prevItemDepth?: number
|
||||
/**
|
||||
* Any data needed to be passed along to the "read more" items. Keep this
|
||||
* trim for better memory usage.
|
||||
*/
|
||||
postData: {
|
||||
uri: string
|
||||
authorHandle: string
|
||||
}
|
||||
/**
|
||||
* The total number of replies to this post, including those not hydrated
|
||||
* and returned by the response.
|
||||
*/
|
||||
repliesCount: number
|
||||
/**
|
||||
* The number of replies to this post not hydrated and returned by the
|
||||
* response.
|
||||
*/
|
||||
repliesUnhydrated: number
|
||||
/**
|
||||
* The number of replies that have been seen so far in the traversal.
|
||||
* Excludes replies that are moderated in some way, since those are not
|
||||
* "seen" on first load. Use `repliesIndexCounter` for the total number of
|
||||
* replies that were hydrated in the response.
|
||||
*
|
||||
* After traversal, we can use this to calculate if we actually got all the
|
||||
* replies we expected, or if some were blocked, etc.
|
||||
*/
|
||||
repliesSeenCounter: number
|
||||
/**
|
||||
* The total number of replies to this post hydrated in this response. Used
|
||||
* for populating the `replyIndex` of the post by referencing this value on
|
||||
* the parent.
|
||||
*/
|
||||
repliesIndexCounter: number
|
||||
/**
|
||||
* The index-0-based index of this reply in the parent post's replies.
|
||||
*/
|
||||
replyIndex: number
|
||||
/**
|
||||
* Each slice is responsible for rendering reply lines based on its depth.
|
||||
* This value corresponds to any line indices that can be skipped e.g.
|
||||
* because there are no further replies below this sub-tree to render.
|
||||
*/
|
||||
skippedIndentIndices: Set<number>
|
||||
/**
|
||||
* Indicates and stores parent data IF that parent has additional unhydrated
|
||||
* replies. This value is passed down to children along the left/lower-most
|
||||
* branch of the tree. When the end is reached, a "read more" is inserted.
|
||||
*/
|
||||
upcomingParentReadMore?: TraversalMetadata
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
import {isDevMode} from '#/storage/hooks/dev-mode'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function getThreadgateRecord(
|
||||
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
|
||||
) {
|
||||
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
|
||||
view?.record,
|
||||
AppBskyFeedThreadgate.isRecord,
|
||||
)
|
||||
? view?.record
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function getRootPostAtUri(post: AppBskyFeedDefs.PostView) {
|
||||
if (
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
if (post.record.reply?.root?.uri) {
|
||||
return new AtUri(post.record.reply.root.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPostRecord(post: AppBskyFeedDefs.PostView) {
|
||||
return post.record as AppBskyFeedPost.Record
|
||||
}
|
||||
|
||||
export function getTraversalMetadata({
|
||||
item,
|
||||
prevItem,
|
||||
nextItem,
|
||||
parentMetadata,
|
||||
}: {
|
||||
item: ApiThreadItem
|
||||
prevItem?: ApiThreadItem
|
||||
nextItem?: ApiThreadItem
|
||||
parentMetadata?: TraversalMetadata
|
||||
}): TraversalMetadata {
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) {
|
||||
throw new Error(`Expected thread item to be a post`)
|
||||
}
|
||||
const repliesCount = item.value.post.replyCount || 0
|
||||
const repliesUnhydrated = item.value.moreReplies || 0
|
||||
const metadata = {
|
||||
depth: item.depth,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
isLastChild: false,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
isLastSibling: false,
|
||||
/*
|
||||
* If it's a top level reply, bc we render each top-level branch as a
|
||||
* separate tree, it's implicitly part of the last branch. For subsequent
|
||||
* replies, we'll override this after traversal.
|
||||
*/
|
||||
isPartOfLastBranchFromDepth: item.depth === 1 ? 1 : undefined,
|
||||
nextItemDepth: nextItem?.depth,
|
||||
parentMetadata,
|
||||
prevItemDepth: prevItem?.depth,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
precedesChildReadMore: false,
|
||||
/*
|
||||
* Unknown until after traversal
|
||||
*/
|
||||
followsReadMoreUp: false,
|
||||
postData: {
|
||||
uri: item.uri,
|
||||
authorHandle: item.value.post.author.handle,
|
||||
},
|
||||
repliesCount,
|
||||
repliesUnhydrated,
|
||||
repliesSeenCounter: 0,
|
||||
repliesIndexCounter: 0,
|
||||
replyIndex: 0,
|
||||
skippedIndentIndices: new Set<number>(),
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
// @ts-ignore dev only for debugging
|
||||
metadata.postData.text = getPostRecord(item.value.post).text
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
export function storeTraversalMetadata(
|
||||
metadatas: Map<string, TraversalMetadata>,
|
||||
metadata: TraversalMetadata,
|
||||
) {
|
||||
metadatas.set(metadata.postData.uri, metadata)
|
||||
|
||||
if (isDevMode()) {
|
||||
// @ts-ignore dev only for debugging
|
||||
metadatas.set(metadata.postData.text, metadata)
|
||||
// @ts-ignore
|
||||
window.__thread = metadatas
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPostUI({
|
||||
depth,
|
||||
repliesCount,
|
||||
prevItemDepth,
|
||||
isLastChild,
|
||||
skippedIndentIndices,
|
||||
repliesSeenCounter,
|
||||
repliesUnhydrated,
|
||||
precedesChildReadMore,
|
||||
followsReadMoreUp,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'threadPost'}>['ui'] {
|
||||
const isReplyAndHasReplies =
|
||||
depth > 0 &&
|
||||
repliesCount > 0 &&
|
||||
(repliesCount - repliesUnhydrated === repliesSeenCounter ||
|
||||
repliesSeenCounter > 0)
|
||||
return {
|
||||
isAnchor: depth === 0,
|
||||
showParentReplyLine:
|
||||
followsReadMoreUp ||
|
||||
(!!prevItemDepth && prevItemDepth !== 0 && prevItemDepth < depth),
|
||||
showChildReplyLine: depth < 0 || isReplyAndHasReplies,
|
||||
indent: depth,
|
||||
/*
|
||||
* If there are no slices below this one, or the next slice has a depth <=
|
||||
* than the depth of this post, it's the last child of the reply tree. It
|
||||
* is not necessarily the last leaf in the parent branch, since it could
|
||||
* have another sibling.
|
||||
*/
|
||||
isLastChild,
|
||||
skippedIndentIndices,
|
||||
precedesChildReadMore: precedesChildReadMore ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export function getThreadPostNoUnauthenticatedUI({
|
||||
depth,
|
||||
prevItemDepth,
|
||||
}: {
|
||||
depth: number
|
||||
prevItemDepth?: number
|
||||
nextItemDepth?: number
|
||||
}): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}>['ui'] {
|
||||
return {
|
||||
showChildReplyLine: depth < 0,
|
||||
showParentReplyLine: Boolean(prevItemDepth && prevItemDepth < depth),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
type ThreadItem,
|
||||
type TraversalMetadata,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
|
||||
export function threadPostNoUnauthenticated({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNoUnauthenticated'}> {
|
||||
return {
|
||||
type: 'threadPostNoUnauthenticated',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated,
|
||||
// @ts-ignore populated by the traversal
|
||||
ui: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPostNotFound({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostNotFound'}> {
|
||||
return {
|
||||
type: 'threadPostNotFound',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPostBlocked({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
}: ApiThreadItem): Extract<ThreadItem, {type: 'threadPostBlocked'}> {
|
||||
return {
|
||||
type: 'threadPostBlocked',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: value as AppBskyUnspeccedDefs.ThreadItemBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
export function threadPost({
|
||||
uri,
|
||||
depth,
|
||||
value,
|
||||
moderationOpts,
|
||||
threadgateHiddenReplies,
|
||||
}: {
|
||||
uri: string
|
||||
depth: number
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
moderationOpts: ModerationOpts
|
||||
threadgateHiddenReplies: Set<string>
|
||||
}): Extract<ThreadItem, {type: 'threadPost'}> {
|
||||
const moderation = moderatePost(value.post, moderationOpts)
|
||||
const modui = moderation.ui('contentList')
|
||||
const blurred = modui.blur || modui.filter
|
||||
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
|
||||
const hiddenByThreadgate = threadgateHiddenReplies.has(uri)
|
||||
const isBlurred = hiddenByThreadgate || blurred || muted
|
||||
return {
|
||||
type: 'threadPost',
|
||||
key: uri,
|
||||
uri,
|
||||
depth,
|
||||
value: {
|
||||
...value,
|
||||
/*
|
||||
* Do not spread anything here, load bearing for post shadow strict
|
||||
* equality reference checks.
|
||||
*/
|
||||
post: value.post as Omit<AppBskyFeedDefs.PostView, 'record'> & {
|
||||
record: AppBskyFeedPost.Record
|
||||
},
|
||||
},
|
||||
isBlurred,
|
||||
moderation,
|
||||
// @ts-ignore populated by the traversal
|
||||
ui: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function readMore({
|
||||
depth,
|
||||
repliesUnhydrated,
|
||||
skippedIndentIndices,
|
||||
postData,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMore'}> {
|
||||
const urip = new AtUri(postData.uri)
|
||||
const href = makeProfileLink(
|
||||
{
|
||||
did: urip.host,
|
||||
handle: postData.authorHandle,
|
||||
},
|
||||
'post',
|
||||
urip.rkey,
|
||||
)
|
||||
return {
|
||||
type: 'readMore' as const,
|
||||
key: `readMore:${postData.uri}`,
|
||||
href,
|
||||
moreReplies: repliesUnhydrated,
|
||||
depth,
|
||||
skippedIndentIndices,
|
||||
}
|
||||
}
|
||||
|
||||
export function readMoreUp({
|
||||
postData,
|
||||
}: TraversalMetadata): Extract<ThreadItem, {type: 'readMoreUp'}> {
|
||||
const urip = new AtUri(postData.uri)
|
||||
const href = makeProfileLink(
|
||||
{
|
||||
did: urip.host,
|
||||
handle: postData.authorHandle,
|
||||
},
|
||||
'post',
|
||||
urip.rkey,
|
||||
)
|
||||
return {
|
||||
type: 'readMoreUp' as const,
|
||||
key: `readMoreUp:${postData.uri}`,
|
||||
href,
|
||||
}
|
||||
}
|
||||
|
||||
export function skeleton({
|
||||
key,
|
||||
item,
|
||||
}: Omit<Extract<ThreadItem, {type: 'skeleton'}>, 'type'>): Extract<
|
||||
ThreadItem,
|
||||
{type: 'skeleton'}
|
||||
> {
|
||||
return {
|
||||
type: 'skeleton',
|
||||
key,
|
||||
item,
|
||||
}
|
||||
}
|
||||
|
||||
export function postViewToThreadPlaceholder(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
): $Typed<
|
||||
Omit<AppBskyUnspeccedGetPostThreadV2.ThreadItem, 'value'> & {
|
||||
value: $Typed<AppBskyUnspeccedDefs.ThreadItemPost>
|
||||
}
|
||||
> {
|
||||
return {
|
||||
$type: 'app.bsky.unspecced.getPostThreadV2#threadItem',
|
||||
uri: post.uri,
|
||||
depth: 0, // reset to 0 for highlighted post
|
||||
value: {
|
||||
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
||||
post,
|
||||
opThread: false,
|
||||
moreParents: false,
|
||||
moreReplies: 0,
|
||||
hiddenByThreadgate: false,
|
||||
mutedByViewer: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user