[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:
Eric Bailey
2025-06-11 14:32:14 -05:00
committed by GitHub
parent 143d5f3b81
commit 61004b887b
60 changed files with 5416 additions and 86 deletions
+27
View File
@@ -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
+325
View File
@@ -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,
}
}
+227
View File
@@ -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
}
+170
View File
@@ -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),
}
}
+183
View File
@@ -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,
},
}
}