This commit is contained in:
Eric Bailey
2025-05-17 13:40:14 -07:00
parent e82627cf4f
commit 5f586cbec0
8 changed files with 794 additions and 843 deletions
-774
View File
@@ -1,774 +0,0 @@
import {useCallback, useMemo, useState} from 'react'
import {
$Typed,
AtUri,
AppBskyFeedDefs,
AppBskyUnspeccedDefs,
AppBskyFeedThreadgate,
AppBskyUnspeccedGetPostThreadV2,
ModerationOpts,
BskyThreadViewPreference,
moderatePost,
ModerationDecision,
AppBskyEmbedRecord,
AppBskyFeedPost,
APP_BSKY_UNSPECCED,
} from '@atproto/api'
import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import * as bsky from '#/types/bsky'
import {
didOrHandleUriMatches,
embedViewRecordToPostView,
getEmbeddedPost,
} from './util'
import {
findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData,
} from '#/state/queries/explore-feed-previews'
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {
findAllPostsInQueryData as findAllPostsInSearchQueryData,
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
} from '#/state/queries/search-posts'
import {
findAllPostsInQueryData as findAllPostsInNotifsQueryData,
findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
} from './notifications/feed'
import {
findAllPostsInQueryData as findAllPostsInFeedQueryData,
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
} from './post-feed'
export type PostThreadV2Params = {
view: 'tree' | 'linear'
sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string
prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers']
}
export const getPostThreadV2QueryKeyRoot = 'getPostThreadV2' as const
export const createGetPostThreadV2QueryKey = (
props: Pick<GetPostThreadV2QueryProps, 'uri' | 'params'>,
) => [getPostThreadV2QueryKeyRoot, props] as const
export type GetPostThreadV2QueryProps = {
uri?: string
enabled?: boolean
params: PostThreadV2Params
state: {
shownHiddenReplyKinds: Set<HiddenReplyKind>
}
}
export type GetPostThreadV2QueryData = {
slices: Slice[]
threadgate?: AppBskyFeedDefs.ThreadgateView
}
export function mapSortOptionsToSortID(sort: PostThreadV2Params['sort']) {
switch (sort) {
case 'hotness':
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
case 'oldest':
return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest
case 'newest':
return APP_BSKY_UNSPECCED.GetPostThreadV2Newest
case 'most-likes':
return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes
default:
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
}
}
export function useGetPostThreadV2({
uri,
enabled: isEnabled,
params,
state,
}: GetPostThreadV2QueryProps) {
const qc = useQueryClient()
const agent = useAgent()
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
const enabled = isEnabled !== false && !!uri && !!moderationOpts
const query = useQuery({
enabled,
queryKey: createGetPostThreadV2QueryKey({
uri,
params,
}),
async queryFn() {
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
uri: uri!,
branchingFactor: params.view === 'linear' ? 1 : 10,
below: 10,
sorting: mapSortOptionsToSortID(params.sort),
})
return data
},
placeholderData() {
if (!uri) return
const placeholder = getSlicePlaceholder(qc, uri)
if (placeholder) {
return {thread: [placeholder]}
}
return
},
select(data) {
const threadgate = getThreadgate(data.threadgate)
return {
...data,
threadgate: {
...data.threadgate,
record: threadgate,
},
}
},
})
// TODO map over pages, just like feeds
//
// - sort up just-posted
// - sort down hidden
// - sort down muted
// - sort down blurred?
const {items, insertReplies} = useThread(query.data?.thread || [], {
hasSession,
params,
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
query.data?.threadgate?.record,
),
moderationOpts: moderationOpts!,
shownHiddenReplyKinds: state.shownHiddenReplyKinds,
})
return {
...query,
data: {
slices: items,
threadgate: query.data?.threadgate,
},
insertReplies,
}
}
const views = {
noUnauthenticated({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
}): Extract<Slice, {type: 'threadSliceNoUnauthenticated'}> {
return {
type: 'threadSliceNoUnauthenticated',
key: item.uri,
slice: item,
}
},
notFound({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemNotFound
}): Extract<Slice, {type: 'threadSliceNotFound'}> {
return {
type: 'threadSliceNotFound',
key: item.uri,
slice: item,
}
},
blocked({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemBlocked
}): Extract<Slice, {type: 'threadSliceBlocked'}> {
return {
type: 'threadSliceBlocked',
key: item.uri,
slice: item,
}
},
post({
item,
oneUp,
oneDown,
moderationOpts,
}: {
item: AppBskyUnspeccedDefs.ThreadItemPost
oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
moderationOpts: ModerationOpts
}): Extract<Slice, {type: 'threadSlice'}> {
return {
type: 'threadSlice',
key: item.uri,
slice: {
...item,
post: {
...item.post,
record: item.post.record as AppBskyFeedPost.Record,
},
},
moderation: moderatePost(item.post, moderationOpts),
ui: {
isAnchor: item.depth === 0,
showParentReplyLine:
!!oneUp && 'depth' in oneUp && oneUp.depth < item.depth,
showChildReplyLine:
!!oneDown && 'depth' in oneDown && oneDown.depth > item.depth,
},
}
},
}
/**
* 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)
* ├── branch ───── (0, 1)
* ├─┬ branch ───── (1, 1) (start)
* │ ├──┬ leaf ──── (2, 2)
* │ │ └── leaf ── (3, 3)
* │ └── leaf ───── (4, 2) (end)
* ├── branch ───── (5, 1)
* ├── branch ───── (6, 1)
*
* const { start: 1, end: 3 } = getBranch(items, 1, 1)
*/
function getBranch(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
branchStartIndex: number,
branchStartDepth: number,
) {
let end = branchStartIndex
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
const next = thread[ci]
// ignore unknowns
if (!('depth' in next)) continue
if (next.depth > branchStartDepth) {
end = ci
} else {
end = ci - 1
break
}
}
return {
start: branchStartIndex,
end,
}
}
export function useThread(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
{
hasSession,
params,
threadgateHiddenReplies,
moderationOpts,
shownHiddenReplyKinds,
}: {
hasSession: boolean
params: PostThreadV2Params
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
shownHiddenReplyKinds: Set<HiddenReplyKind>
},
) {
const isTreeView = params.view === 'tree'
const [shadowSlices, setShadowSlices] = useState<
Record<string, AppBskyUnspeccedDefs.ThreadItemPost[][]>
>({})
const insertReplies = useCallback((belowUri: string, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
setShadowSlices(p => {
const prev = {...p}
if (belowUri in prev) {
prev[belowUri] = [posts, ...(prev[belowUri] || {})]
} else {
prev[belowUri] = [posts]
}
return prev
})
}, [setShadowSlices])
const sorted = useMemo(() => {
return sortThread(thread, {
threadgateHiddenReplies,
moderationOpts,
})
}, [thread, threadgateHiddenReplies, moderationOpts])
const flattened = useMemo(() => {
const showMuted = shownHiddenReplyKinds.has(HiddenReplyKind.Muted)
const showHidden = shownHiddenReplyKinds.has(HiddenReplyKind.Hidden)
if (sorted.hidden.length) {
if (showHidden) {
sorted.slices.push(...sorted.hidden)
if (sorted.muted.length) {
if (showMuted) {
sorted.slices.push(...sorted.muted)
} else {
sorted.slices.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
} else {
sorted.slices.push({
type: 'showHiddenReplies',
key: 'showHiddenReplies',
kind: HiddenReplyKind.Hidden,
})
}
} else if (sorted.muted.length) {
if (showMuted) {
sorted.slices.push(...sorted.muted)
} else {
sorted.slices.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
return sorted.slices
}, [sorted, shownHiddenReplyKinds])
// TODO should probably done in query cache so deletes flow through the same way
const optimistic = useMemo(() => {
for (let i = 0; i < flattened.length; i++) {
const item = flattened[i]
if (item.type !== 'threadSlice') continue
if (!(item.slice.uri in shadowSlices)) continue
const replyThreads = shadowSlices[item.slice.uri]
console.log(replyThreads)
// TODO linear view will work
if (!isTreeView) continue
for (const thread of replyThreads!) {
for (let ri = 0; ri < thread.length; ri++) {
const post = thread[ri]
post.depth = item.slice.depth + 1 + ri
const view = views.post({
item: post,
oneUp: undefined,
oneDown: undefined,
moderationOpts,
})
const insertIndex = i + 1 + ri
flattened.splice(
insertIndex,
// if same post, replace in situ
flattened[insertIndex]?.key === view.key ? 1 : 0,
view
)
console.log('insert', {
post: post.post.record?.text,
depth: item.slice.depth + 1 + ri,
ri,
spliceIndex: i + 1 + ri,
tree: flattened.map(f => f.slice?.post?.record?.text),
})
}
// TODO may not need since inserts will be looped and ignored
// console.log('prev', i)
// i = i - thread.length
// console.log('next', i)
}
}
return [...flattened]
}, [flattened, shadowSlices, isTreeView])
const items = useMemo(() => {
if (hasSession) {
for (let i = 0; i < optimistic.length; i++) {
const slice = optimistic[i]
if ('slice' in slice && slice.slice.depth === 0) {
optimistic.splice(i + 1, 0, {
type: 'replyComposer',
key: 'replyComposer',
})
break
}
}
}
return optimistic
}, [optimistic, hasSession])
return useMemo(
() => ({
items,
insertReplies,
}),
[items, insertReplies],
)
}
export function sortThread(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
{
threadgateHiddenReplies,
moderationOpts,
}: {
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
},
) {
const slices: Slice[] = []
const hidden: Slice[] = []
const muted: Slice[] = []
traversal: for (let i = 0; i < thread.length; i++) {
const item = thread[i]
// ignore unknowns
if (!('depth' in item)) continue
if (item.depth < 0) {
/*
* Parents are ignored until we find the highlighted post, then we walk
* _up_ from there.
*/
} else if (item.depth === 0) {
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) {
slices.push(views.noUnauthenticated({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) {
slices.push(views.notFound({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) {
slices.push(views.blocked({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
slices.push(
views.post({
item,
oneUp: thread[i - 1],
oneDown: thread[i + 1],
moderationOpts,
}),
)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parentOneDown = thread[pi + 1]
const parent = thread[pi]
const parentOneUp = thread[pi - 1]
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) {
slices.unshift(views.noUnauthenticated({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) {
slices.unshift(views.notFound({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) {
slices.unshift(views.blocked({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) {
slices.unshift(
views.post({
item: parent,
oneUp: parentOneUp,
oneDown: parentOneDown,
moderationOpts,
}),
)
}
}
}
} 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) ||
AppBskyUnspeccedDefs.isThreadItemNotFound(item) ||
AppBskyUnspeccedDefs.isThreadItemBlocked(item)
if (shouldBreak) {
const branch = getBranch(thread, i, item.depth)
// could insert tombstone
i = branch.end
continue traversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
const lastSlice = slices[slices.length - 1]
const isFirstReply =
lastSlice.type === 'replyComposer' ||
(lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0)
const parent = views.post({
item,
oneUp: isFirstReply ? undefined : thread[i - 1],
oneDown: thread[i + 1],
moderationOpts,
})
const parentMod = getModerationState(parent.moderation)
const parentIsHidden = threadgateHiddenReplies.has(item.uri)
const parentIsTopLevelReply = item.depth === 1
const parentIsModerated =
parentIsHidden || parentMod.blurred || parentMod.muted
if (!parentIsModerated) {
/*
* Not hidden, so show it
*/
slices.push(parent)
} else {
const branch = getBranch(thread, i, item.depth)
const sortArray = parentMod.muted ? muted : hidden
if (parentIsTopLevelReply) {
// push branch anchor into sorted array
sortArray.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)) {
const childPost = views.post({
item: child,
oneUp: thread[ci - 1],
oneDown: thread[ci + 1],
moderationOpts,
})
const childMod = getModerationState(childPost.moderation)
const childIsHidden = threadgateHiddenReplies.has(child.uri)
/*
* If a child is hidden in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the
* parent post directly.
*/
if (childMod.blurred || childMod.muted || childIsHidden) {
ci = getBranch(thread, ci, child.depth).end
} else {
sortArray.push(childPost)
}
} else {
/*
* Drop the rest of the branch if we hit anything unexpected
*/
break
}
}
}
/*
* Skip to next branch
*/
i = branch.end
continue traversal
}
}
}
}
return {
slices,
hidden,
muted,
}
}
export function getModerationState(moderation: ModerationDecision) {
const modui = moderation.ui('contentList')
const blurred = modui.blur || modui.filter
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
return {
blurred,
muted,
}
}
export enum HiddenReplyKind {
Hidden = 'hidden',
Muted = 'muted',
}
export type Slice =
| {
type: 'threadSlice'
key: string
slice: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
record: AppBskyFeedPost.Record
}
}
moderation: ModerationDecision
ui: {
isAnchor: boolean
showParentReplyLine: boolean
showChildReplyLine: boolean
}
}
| {
type: 'threadSliceNoUnauthenticated'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
}
| {
type: 'threadSliceNotFound'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemNotFound
}
| {
type: 'threadSliceBlocked'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemBlocked
}
| {
type: 'replyComposer'
key: string
}
| {
type: 'showHiddenReplies'
key: string
kind: HiddenReplyKind
}
function getThreadgate(
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
) {
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
view?.record,
AppBskyFeedThreadgate.isRecord,
)
? view?.record
: undefined
}
function getSlicePlaceholder(
queryClient: QueryClient,
uri: string,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> | void {
let partial
for (let item of yieldPlaceholdersFromQueryCache(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.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* yieldPlaceholdersFromQueryCache(
queryClient: QueryClient,
uri: string,
): Generator<$Typed<AppBskyUnspeccedDefs.ThreadItemPost>, void> {
const atUri = new AtUri(uri)
/*
* Check this thread in the cache first.
* TODO extract just this for shadowing
*/
const queryDatas =
queryClient.getQueriesData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>({
queryKey: [getPostThreadV2QueryKeyRoot],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) continue
const {thread} = queryData
for (const item of thread) {
if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
if (didOrHandleUriMatches(atUri, item.post)) {
yield {
...item,
depth: 0,
}
}
const qp = getEmbeddedPost(item.post.embed)
if (qp && didOrHandleUriMatches(atUri, qp)) {
yield embedViewToSlicePlaceholder(qp)
}
}
}
}
/*
* 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 postViewToSlicePlaceholder(post)
}
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToSlicePlaceholder(post)
}
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
yield postViewToSlicePlaceholder(post)
}
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield postViewToSlicePlaceholder(post)
}
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
queryClient,
uri,
)) {
yield postViewToSlicePlaceholder(post)
}
}
function postViewToSlicePlaceholder(
post: AppBskyFeedDefs.PostView,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
return {
$type: 'app.bsky.unspecced.defs#threadItemPost',
uri: post.uri,
post,
depth: 0, // reset to 0 for highlighted post
isOPThread: false, // unknown
hasOPLike: false, // unknown
hasUnhydratedReplies: false, // unknown
// TODO test
hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown
}
}
function embedViewToSlicePlaceholder(
record: AppBskyEmbedRecord.ViewRecord,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
return {
$type: 'app.bsky.unspecced.defs#threadItemPost',
uri: record.uri,
post: embedViewRecordToPostView(record),
depth: 0, // reset to 0 for highlighted post
isOPThread: false, // unknown
hasOPLike: false, // unknown
hasUnhydratedReplies: false, // unknown
// TODO test
hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown
}
}
+93
View File
@@ -0,0 +1,93 @@
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {getThreadPlaceholder} from '#/state/queries/usePostThread/queryCache'
import {flatten,sort} from '#/state/queries/usePostThread/traversal'
import {
createPostThreadQueryKey,
HiddenReplyKind,
type UsePostThreadProps,
} from '#/state/queries/usePostThread/types'
import {
getThreadgateRecord,
mapSortOptionsToSortID,
} from '#/state/queries/usePostThread/utils'
import {useAgent, useSession} from '#/state/session'
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
export * from '#/state/queries/usePostThread/types'
export function usePostThread({
uri,
enabled: isEnabled,
params,
state,
}: UsePostThreadProps) {
const qc = useQueryClient()
const agent = useAgent()
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies()
const enabled = isEnabled !== false && !!uri && !!moderationOpts
const query = useQuery({
enabled,
queryKey: createPostThreadQueryKey({
uri,
params,
}),
async queryFn() {
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
uri: uri!,
branchingFactor: params.view === 'linear' ? 1 : 10,
below: 10,
sorting: mapSortOptionsToSortID(params.sort),
})
return data
},
placeholderData() {
if (!uri) return
const placeholder = getThreadPlaceholder(qc, uri)
if (placeholder) {
return {thread: [placeholder]}
}
return
},
select(data) {
const threadgate = getThreadgateRecord(data.threadgate)
return {
...data,
threadgate: {
...data.threadgate,
record: threadgate,
},
}
},
})
// TODO map over pages, just like feeds
const items = flatten(
sort(query.data?.thread || [], {
threadgateHiddenReplies: mergeThreadgateHiddenReplies(
query.data?.threadgate?.record,
),
moderationOpts: moderationOpts!,
}),
{
hasSession,
showMuted: state.shownHiddenReplyKinds.has(HiddenReplyKind.Muted),
showHidden: state.shownHiddenReplyKinds.has(HiddenReplyKind.Hidden),
},
)
return {
...query,
data: {
slices: items,
threadgate: query.data?.threadgate,
},
insertReplies: () => {},
}
}
@@ -0,0 +1,106 @@
import {
type $Typed,
AppBskyUnspeccedDefs,
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 {postThreadQueryKeyRoot} from '#/state/queries/usePostThread/types'
import {
embedViewToThreadPlaceholder,
postViewToThreadPlaceholder,
} from '#/state/queries/usePostThread/views'
import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util'
export function getThreadPlaceholder(
queryClient: QueryClient,
uri: string,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> | 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.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<AppBskyUnspeccedDefs.ThreadItemPost>, void> {
const atUri = new AtUri(uri)
/*
* Check this thread in the cache first.
* TODO extract just this for shadowing
*/
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)) {
if (didOrHandleUriMatches(atUri, item.post)) {
yield {
...item,
depth: 0,
}
}
const qp = getEmbeddedPost(item.post.embed)
if (qp && didOrHandleUriMatches(atUri, qp)) {
yield embedViewToThreadPlaceholder(qp)
}
}
}
}
/*
* 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)
}
}
@@ -0,0 +1,289 @@
import {
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {HiddenReplyKind,type Slice} from '#/state/queries/usePostThread/types'
import * as views from '#/state/queries/usePostThread/views'
export function flatten(
sorted: ReturnType<typeof sort>,
{
hasSession,
showMuted,
showHidden,
}: {
hasSession: boolean
showMuted: boolean
showHidden: boolean
},
) {
const flattened: Slice[] = sorted.slices
if (sorted.hidden.length) {
if (showHidden) {
flattened.push(...sorted.hidden)
if (sorted.muted.length) {
if (showMuted) {
flattened.push(...sorted.muted)
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showHiddenReplies',
kind: HiddenReplyKind.Hidden,
})
}
} else if (sorted.muted.length) {
if (showMuted) {
flattened.push(...sorted.muted)
} else {
flattened.push({
type: 'showHiddenReplies',
key: 'showMutedReplies',
kind: HiddenReplyKind.Muted,
})
}
}
if (hasSession) {
for (let i = 0; i < flattened.length; i++) {
const slice = flattened[i]
if ('slice' in slice && slice.slice.depth === 0) {
flattened.splice(i + 1, 0, {
type: 'replyComposer',
key: 'replyComposer',
})
break
}
}
}
return flattened
}
export function sort(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
{
threadgateHiddenReplies,
moderationOpts,
}: {
threadgateHiddenReplies: Set<string>
moderationOpts: ModerationOpts
},
) {
const slices: Slice[] = []
const hidden: Slice[] = []
const muted: Slice[] = []
traversal: for (let i = 0; i < thread.length; i++) {
const item = thread[i]
// ignore unknowns
if (!('depth' in item)) continue
if (item.depth < 0) {
/*
* Parents are ignored until we find the highlighted post, then we walk
* _up_ from there.
*/
} else if (item.depth === 0) {
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(item)) {
slices.push(views.noUnauthenticated({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(item)) {
slices.push(views.notFound({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(item)) {
slices.push(views.blocked({item}))
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
slices.push(
views.post({
item,
oneUp: thread[i - 1],
oneDown: thread[i + 1],
moderationOpts,
}),
)
parentTraversal: for (let pi = i - 1; pi >= 0; pi--) {
const parentOneDown = thread[pi + 1]
const parent = thread[pi]
const parentOneUp = thread[pi - 1]
if (AppBskyUnspeccedDefs.isThreadItemNoUnauthenticated(parent)) {
slices.unshift(views.noUnauthenticated({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemNotFound(parent)) {
slices.unshift(views.notFound({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemBlocked(parent)) {
slices.unshift(views.blocked({item: parent}))
break parentTraversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(parent)) {
slices.unshift(
views.post({
item: parent,
oneUp: parentOneUp,
oneDown: parentOneDown,
moderationOpts,
}),
)
}
}
}
} 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) ||
AppBskyUnspeccedDefs.isThreadItemNotFound(item) ||
AppBskyUnspeccedDefs.isThreadItemBlocked(item)
if (shouldBreak) {
const branch = getBranch(thread, i, item.depth)
// could insert tombstone
i = branch.end
continue traversal
} else if (AppBskyUnspeccedDefs.isThreadItemPost(item)) {
const lastSlice = slices[slices.length - 1]
const isFirstReply =
lastSlice.type === 'replyComposer' ||
(lastSlice.type === 'threadSlice' && lastSlice.slice.depth === 0)
const parent = views.post({
item,
oneUp: isFirstReply ? undefined : thread[i - 1],
oneDown: thread[i + 1],
moderationOpts,
})
const parentMod = getModerationState(parent.moderation)
const parentIsHidden = threadgateHiddenReplies.has(item.uri)
const parentIsTopLevelReply = item.depth === 1
const parentIsModerated =
parentIsHidden || parentMod.blurred || parentMod.muted
if (!parentIsModerated) {
/*
* Not hidden, so show it
*/
slices.push(parent)
} else {
const branch = getBranch(thread, i, item.depth)
const sortArray = parentMod.muted ? muted : hidden
if (parentIsTopLevelReply) {
// push branch anchor into sorted array
sortArray.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)) {
const childPost = views.post({
item: child,
oneUp: thread[ci - 1],
oneDown: thread[ci + 1],
moderationOpts,
})
const childMod = getModerationState(childPost.moderation)
const childIsHidden = threadgateHiddenReplies.has(child.uri)
/*
* If a child is hidden in any way, drop it an its sub-branch
* entirely. To reveal these, the user must navigate to the
* parent post directly.
*/
if (childMod.blurred || childMod.muted || childIsHidden) {
ci = getBranch(thread, ci, child.depth).end
} else {
sortArray.push(childPost)
}
} else {
/*
* Drop the rest of the branch if we hit anything unexpected
*/
break
}
}
}
/*
* Skip to next branch
*/
i = branch.end
continue traversal
}
}
}
}
return {
slices,
hidden,
muted,
}
}
/**
* 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)
* ├── branch ───── (0, 1)
* ├─┬ branch ───── (1, 1) (start)
* │ ├──┬ leaf ──── (2, 2)
* │ │ └── leaf ── (3, 3)
* │ └── leaf ───── (4, 2) (end)
* ├── branch ───── (5, 1)
* ├── branch ───── (6, 1)
*
* const { start: 1, end: 3 } = getBranch(items, 1, 1)
*/
function getBranch(
thread: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'],
branchStartIndex: number,
branchStartDepth: number,
) {
let end = branchStartIndex
for (let ci = branchStartIndex + 1; ci < thread.length; ci++) {
const next = thread[ci]
// ignore unknowns
if (!('depth' in next)) continue
if (next.depth > branchStartDepth) {
end = ci
} else {
end = ci - 1
break
}
}
return {
start: branchStartIndex,
end,
}
}
export function getModerationState(moderation: ModerationDecision) {
const modui = moderation.ui('contentList')
const blurred = modui.blur || modui.filter
const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted'
return {
blurred,
muted,
}
}
+74
View File
@@ -0,0 +1,74 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyUnspeccedDefs,
type BskyThreadViewPreference,
type ModerationDecision,
} from '@atproto/api'
export const postThreadQueryKeyRoot = 'getPostThreadV2' as const
export const createPostThreadQueryKey = (
props: Pick<UsePostThreadProps, 'uri' | 'params'>,
) => [postThreadQueryKeyRoot, props] as const
export type PostThreadParams = {
view: 'tree' | 'linear'
sort: 'hotness' | 'oldest' | 'newest' | 'most-likes' | 'random' | string
prioritizeFollows: BskyThreadViewPreference['prioritizeFollowedUsers']
}
export type UsePostThreadProps = {
uri?: string
enabled?: boolean
params: PostThreadParams
state: {
shownHiddenReplyKinds: Set<HiddenReplyKind>
}
}
export enum HiddenReplyKind {
Hidden = 'hidden',
Muted = 'muted',
}
export type Slice =
| {
type: 'threadSlice'
key: string
slice: Omit<AppBskyUnspeccedDefs.ThreadItemPost, 'post'> & {
post: Omit<AppBskyFeedDefs.PostView, 'record'> & {
record: AppBskyFeedPost.Record
}
}
moderation: ModerationDecision
ui: {
isAnchor: boolean
showParentReplyLine: boolean
showChildReplyLine: boolean
}
}
| {
type: 'threadSliceNoUnauthenticated'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
}
| {
type: 'threadSliceNotFound'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemNotFound
}
| {
type: 'threadSliceBlocked'
key: string
slice: AppBskyUnspeccedDefs.ThreadItemBlocked
}
| {
type: 'replyComposer'
key: string
}
| {
type: 'showHiddenReplies'
key: string
kind: HiddenReplyKind
}
+34
View File
@@ -0,0 +1,34 @@
import {
APP_BSKY_UNSPECCED,
AppBskyFeedThreadgate,
type AppBskyUnspeccedGetPostThreadV2,
} from '@atproto/api'
import {type PostThreadParams} from '#/state/queries/usePostThread/types'
import * as bsky from '#/types/bsky'
export function mapSortOptionsToSortID(sort: PostThreadParams['sort']) {
switch (sort) {
case 'hotness':
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
case 'oldest':
return APP_BSKY_UNSPECCED.GetPostThreadV2Oldest
case 'newest':
return APP_BSKY_UNSPECCED.GetPostThreadV2Newest
case 'most-likes':
return APP_BSKY_UNSPECCED.GetPostThreadV2MostLikes
default:
return APP_BSKY_UNSPECCED.GetPostThreadV2Hotness
}
}
export function getThreadgateRecord(
view: AppBskyUnspeccedGetPostThreadV2.OutputSchema['threadgate'],
) {
return bsky.dangerousIsType<AppBskyFeedThreadgate.Record>(
view?.record,
AppBskyFeedThreadgate.isRecord,
)
? view?.record
: undefined
}
+113
View File
@@ -0,0 +1,113 @@
import {
type $Typed,
type AppBskyEmbedRecord,
type AppBskyFeedDefs,
type AppBskyFeedPost,
type AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
moderatePost,
type ModerationOpts,
} from '@atproto/api'
import {type Slice} from '#/state/queries/usePostThread/types'
import {embedViewRecordToPostView} from '#/state/queries/util'
export function noUnauthenticated({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemNoUnauthenticated
}): Extract<Slice, {type: 'threadSliceNoUnauthenticated'}> {
return {
type: 'threadSliceNoUnauthenticated',
key: item.uri,
slice: item,
}
}
export function notFound({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemNotFound
}): Extract<Slice, {type: 'threadSliceNotFound'}> {
return {
type: 'threadSliceNotFound',
key: item.uri,
slice: item,
}
}
export function blocked({
item,
}: {
item: AppBskyUnspeccedDefs.ThreadItemBlocked
}): Extract<Slice, {type: 'threadSliceBlocked'}> {
return {
type: 'threadSliceBlocked',
key: item.uri,
slice: item,
}
}
export function post({
item,
oneUp,
oneDown,
moderationOpts,
}: {
item: AppBskyUnspeccedDefs.ThreadItemPost
oneUp?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
oneDown?: AppBskyUnspeccedGetPostThreadV2.OutputSchema['thread'][number]
moderationOpts: ModerationOpts
}): Extract<Slice, {type: 'threadSlice'}> {
return {
type: 'threadSlice',
key: item.uri,
slice: {
...item,
post: {
...item.post,
record: item.post.record as AppBskyFeedPost.Record,
},
},
moderation: moderatePost(item.post, moderationOpts),
ui: {
isAnchor: item.depth === 0,
showParentReplyLine:
!!oneUp && 'depth' in oneUp && oneUp.depth < item.depth,
showChildReplyLine:
!!oneDown && 'depth' in oneDown && oneDown.depth > item.depth,
},
}
}
export function postViewToThreadPlaceholder(
post: AppBskyFeedDefs.PostView,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
return {
$type: 'app.bsky.unspecced.defs#threadItemPost',
uri: post.uri,
post,
depth: 0, // reset to 0 for highlighted post
isOPThread: false, // unknown
hasOPLike: false, // unknown
hasUnhydratedReplies: false, // unknown
// TODO test
hasUnhydratedParents: !!(post.record as AppBskyFeedPost.Record).reply, // unknown
}
}
export function embedViewToThreadPlaceholder(
record: AppBskyEmbedRecord.ViewRecord,
): $Typed<AppBskyUnspeccedDefs.ThreadItemPost> {
return {
$type: 'app.bsky.unspecced.defs#threadItemPost',
uri: record.uri,
post: embedViewRecordToPostView(record),
depth: 0, // reset to 0 for highlighted post
isOPThread: false, // unknown
hasOPLike: false, // unknown
hasUnhydratedReplies: false, // unknown
// TODO test
hasUnhydratedParents: !!(record.value as AppBskyFeedPost.Record).reply, // unknown
}
}
+85 -69
View File
@@ -1,34 +1,39 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {useWindowDimensions, View} from 'react-native'
import {type AppBskyUnspeccedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyUnspeccedDefs} from '@atproto/api'
import {useFocusEffect} from '@react-navigation/native'
import {isNative} from '#/platform/detection'
import {cleanError} from '#/lib/strings/errors'
import {HITSLOP_10} from '#/lib/constants'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {type CommonNavigatorParams, type NativeStackScreenProps} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useSetMinimalShellMode} from '#/state/shell'
import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread'
import * as Layout from '#/components/Layout'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
{
/* import {PostThread as PostThreadComponent} from '#/view/com/post-thread/PostThread' */
}
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useGetPostThreadV2, Slice, HiddenReplyKind} from '#/state/queries/useGetPostThreadV2'
import * as Menu from '#/components/Menu'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {ScrollProvider} from '#/lib/ScrollContext'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {
HiddenReplyKind,
type Slice,
usePostThread,
} from '#/state/queries/usePostThread'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
import {List, type ListMethods} from '#/view/com/util/List'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {ScrollProvider} from '#/lib/ScrollContext'
import {List, ListMethods} from '#/view/com/util/List'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem'
import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {
// We don't insert any elements before the root row while loading.
@@ -91,23 +96,26 @@ function useThreadPreferences() {
const isLoaded = !!prevServerPrefs
return useMemo(() => ({
isLoaded,
sortReplies,
setSortReplies,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers,
treeViewEnabled,
setTreeViewEnabled,
}), [
isLoaded,
sortReplies,
setSortReplies,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers,
treeViewEnabled,
setTreeViewEnabled,
])
return useMemo(
() => ({
isLoaded,
sortReplies,
setSortReplies,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers,
treeViewEnabled,
setTreeViewEnabled,
}),
[
isLoaded,
sortReplies,
setSortReplies,
prioritizeFollowedUsers,
setPrioritizeFollowedUsers,
treeViewEnabled,
setTreeViewEnabled,
],
)
}
export function Inner({uri}: {uri: string | undefined}) {
@@ -127,39 +135,42 @@ export function Inner({uri}: {uri: string | undefined}) {
setTreeViewEnabled,
} = useThreadPreferences()
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<Set<HiddenReplyKind>>(new Set())
const [shownHiddenReplyKinds, setShownHiddenReplyKinds] = useState<
Set<HiddenReplyKind>
>(new Set())
const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} = useGetPostThreadV2({
uri,
enabled: isThreadPreferencesLoaded,
params: {
sort: sortReplies,
view: treeViewEnabled ? 'tree' : 'linear',
prioritizeFollows: prioritizeFollowedUsers,
},
state: {
shownHiddenReplyKinds,
}
})
const {isFetching, isPlaceholderData, error, data, refetch, insertReplies} =
usePostThread({
uri,
enabled: isThreadPreferencesLoaded,
params: {
sort: sortReplies,
view: treeViewEnabled ? 'tree' : 'linear',
prioritizeFollows: prioritizeFollowedUsers,
},
state: {
shownHiddenReplyKinds,
},
})
const ref = useRef<ListMethods>(null)
const layoutHeaderRef = useRef<View | null>(null)
const anchorPostRef = useRef<View | null>(null)
const optimisticOnPostReply = ({
post,
}: {
post: AppBskyUnspeccedDefs.ThreadItemPost,
}) => (_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
if (posts.length) {
// TODO get parent and update reply count?
insertReplies(post.uri, posts)
const optimisticOnPostReply =
({post}: {post: AppBskyUnspeccedDefs.ThreadItemPost}) =>
(_: any, posts: AppBskyUnspeccedDefs.ThreadItemPost[]) => {
if (posts.length) {
// TODO get parent and update reply count?
insertReplies(post.uri, posts)
}
}
}
const {openComposer} = useOpenComposer()
const onReplyToAnchor = () => {
const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor)
const anchorPost = data?.slices.find(
slice => slice.type === 'threadSlice' && slice.ui.isAnchor,
)
if (anchorPost?.type !== 'threadSlice') {
return
}
@@ -174,7 +185,7 @@ export function Inner({uri}: {uri: string | undefined}) {
moderation: anchorPost.moderation,
},
// @ts-expect-error TODO
onPost: optimisticOnPostReply({post:anchorPost.slice}),
onPost: optimisticOnPostReply({post: anchorPost.slice}),
})
}
@@ -200,10 +211,11 @@ export function Inner({uri}: {uri: string | undefined}) {
item.ui.showParentReplyLine || !!item.slice.hasUnhydratedParents
} // !!hasUnrevealedParents // TODO
overrideBlur={
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) && item.slice.depth > 0
shownHiddenReplyKinds.has(HiddenReplyKind.Muted) &&
item.slice.depth > 0
}
// @ts-expect-error TODO
onPostReply={optimisticOnPostReply({post:item.slice})}
onPostReply={optimisticOnPostReply({post: item.slice})}
hideTopBorder={index === 0} // && !item.slice.isParentLoading} // TODO
/>
</View>
@@ -239,14 +251,18 @@ export function Inner({uri}: {uri: string | undefined}) {
} else if (item.type === 'replyComposer') {
return (
<View>
{gtPhone && <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />}
{gtPhone && (
<PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
)}
</View>
)
} else if (item.type === 'showHiddenReplies') {
return (
<PostThreadShowHiddenReplies
type={item.kind === 'muted' ? 'muted' : 'hidden'}
onPress={() => setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))}
onPress={() =>
setShownHiddenReplyKinds(kinds => new Set([...kinds, item.kind]))
}
/>
)
}
@@ -324,9 +340,9 @@ export function Inner({uri}: {uri: string | undefined}) {
* @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition
*/
maintainVisibleContentPosition={
isNative // && hasParents // TODO not sure we need this
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
isNative // && hasParents // TODO not sure we need this
? MAINTAIN_VISIBLE_CONTENT_POSITION
: undefined
}
desktopFixedHeight
// removeClippedSubviews={isAndroid ? false : undefined}
@@ -361,7 +377,7 @@ function PostThreadError({error}: {error: Error}) {
const {_} = useLingui()
// TODO use new cleanError hook
const {title, message} = useMemo(() => {
const {title: _title, message: _message} = useMemo(() => {
let title = _(msg`An error occurred`)
let message = cleanError(error)