Files
bsky-social-app/src/screens/PostThread/components/ThreadItemTreePost.tsx
T
Austin McKinley b4dfa3cc22 Collapse label appeal flow into ModerationDetailsDialog (#11091)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-07-10 18:26:10 -05:00

450 lines
13 KiB
TypeScript

import {memo, useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyFeedDefs,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
import {countLines} from '#/lib/strings/helpers'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {type ThreadItem} from '#/state/queries/usePostThread/types'
import {useSession} from '#/state/session'
import {type OnPostSuccessData} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {PostMeta} from '#/view/com/util/PostMeta'
import {
OUTER_SPACE,
REPLY_LINE_WIDTH,
TREE_AVI_WIDTH,
TREE_INDENT,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
import {TranslatedPost} from '#/components/Post/Translated'
import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
/**
* Mimic the space in PostMeta
*/
const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
export function ThreadItemTreePost({
item,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadow = usePostShadow(item.value.post)
if (postShadow === POST_TOMBSTONE) {
return <ThreadItemTreePostDeleted item={item} />
}
return (
<ThreadItemTreePostInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
)
}
function ThreadItemTreePostDeleted({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<ThreadItemTreePostOuterWrapper item={item}>
<ThreadItemTreePostInnerWrapper item={item}>
<View
style={[
a.flex_row,
a.align_center,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
gap: 6,
paddingHorizontal: OUTER_SPACE / 2,
height: TREE_AVI_WIDTH,
},
]}>
<TrashIcon style={[t.atoms.text]} width={14} />
<Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
<Trans>Post has been deleted</Trans>
</Text>
</View>
{item.ui.isLastChild && !item.ui.precedesChildReadMore && (
<View style={{height: OUTER_SPACE / 2}} />
)}
</ThreadItemTreePostInnerWrapper>
</ThreadItemTreePostOuterWrapper>
)
}
const ThreadItemTreePostOuterWrapper = memo(
function ThreadItemTreePostOuterWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
const indents = Math.max(0, item.ui.indent - 1)
return (
<GalleryBleed>
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
</GalleryBleed>
)
},
)
const ThreadItemTreePostInnerWrapper = memo(
function ThreadItemTreePostInnerWrapper({
item,
children,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
children: React.ReactNode
}) {
const t = useTheme()
return (
<View
style={[
a.flex_1, // TODO check on ios
{
paddingHorizontal: OUTER_SPACE,
paddingTop: OUTER_SPACE / 2,
},
item.ui.indent === 1 && [
!item.ui.showParentReplyLine && {paddingTop: OUTER_SPACE / 1.5},
!item.ui.showChildReplyLine && a.pb_sm,
],
item.ui.isLastChild &&
!item.ui.precedesChildReadMore && [
{
paddingBottom: OUTER_SPACE / 2,
},
],
]}>
{item.ui.indent > 1 && (
<View
style={[
a.absolute,
t.atoms.border_contrast_low,
{
left: -1,
top: 0,
height:
TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
width: OUTER_SPACE,
borderLeftWidth: REPLY_LINE_WIDTH,
borderBottomWidth: REPLY_LINE_WIDTH,
borderBottomLeftRadius: a.rounded_sm.borderRadius,
},
]}
/>
)}
{children}
</View>
)
},
)
const ThreadItemTreeReplyChildReplyLine = memo(
function ThreadItemTreeReplyChildReplyLine({
item,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
}) {
const t = useTheme()
return (
<View style={[a.relative, a.pt_2xs, {width: TREE_AVI_PLUS_SPACE}]}>
{item.ui.showChildReplyLine && (
<View
style={[
a.flex_1,
t.atoms.border_contrast_low,
{borderRightWidth: 2, width: '50%', left: -1},
]}
/>
)}
</View>
)
},
)
const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
item,
postShadow,
overrides,
onPostSuccess,
threadgateRecord,
}: {
item: Extract<ThreadItem, {type: 'threadPost'}>
postShadow: Shadow<AppBskyFeedDefs.PostView>
overrides?: {
moderation?: boolean
topBorder?: boolean
}
onPostSuccess?: (data: OnPostSuccessData) => void
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode {
const {openComposer} = useOpenComposer()
const {currentAccount} = useSession()
const post = item.value.post
const record = item.value.post.record
const moderation = item.moderation
const richText = useMemo(
() =>
new RichTextAPI({
text: record.text,
facets: record.facets,
}),
[record],
)
const [limitLines, setLimitLines] = useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
const threadRootUri = record.reply?.root?.uri || post.uri
const postHref = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isControlledByViewer =
new AtUri(threadRootUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
const onPressReply = useCallback(() => {
openComposer({
replyTo: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
embed: post.embed,
moderation,
langs: post.record.langs,
},
onPostSuccess: onPostSuccess,
logContext: 'PostReply',
})
}, [openComposer, post, record, onPostSuccess, moderation])
const onPressShowMore = useCallback(() => {
setLimitLines(false)
}, [setLimitLines])
return (
<ThreadItemTreePostOuterWrapper item={item}>
<SubtleHoverWrapper>
<PostHider
testID={`postThreadItem-by-${post.author.handle}`}
href={postHref}
disabled={overrides?.moderation === true}
modui={moderation.ui('contentList')}
iconSize={42}
iconStyles={{marginLeft: 2, marginRight: 2}}
profile={post.author}
interpretFilterAsBlur>
<ThreadItemTreePostInnerWrapper item={item}>
<View style={[a.flex_1]}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={postHref}
avatarSize={TREE_AVI_WIDTH}
style={[a.pb_0]}
showAvatar
/>
<View style={[a.flex_row]}>
<ThreadItemTreeReplyChildReplyLine item={item} />
<View style={[a.flex_1, a.pl_2xs]}>
<PostAlerts
post={post}
modui={moderation.ui('contentList')}
style={[a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View style={[a.mb_2xs]}>
<RichText
enableTags
value={richText}
style={[a.flex_1, a.text_md]}
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
authorHandle={post.author.handle}
shouldProxyLinks={true}
/>
{limitLines && (
<ShowMoreTextButton
style={[a.text_md]}
onPress={onPressShowMore}
/>
)}
</View>
) : null}
<TranslatedPost hideTranslateLink post={post} />
{post.embed && (
<View style={[a.pb_xs]}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
post={post}
/>
</View>
)}
<PostControls
variant="compact"
post={postShadow}
record={record}
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
<DebugFieldDisplay subject={post} />
</View>
</View>
</View>
</ThreadItemTreePostInnerWrapper>
</PostHider>
</SubtleHoverWrapper>
</ThreadItemTreePostOuterWrapper>
)
})
function SubtleHoverWrapper({children}: {children: React.ReactNode}) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
onPointerEnter={onHoverIn}
onPointerLeave={onHoverOut}
style={[a.flex_1, a.pointer]}>
<SubtleHover hover={hover} />
{children}
</View>
)
}
export function ThreadItemTreePostSkeleton({index}: {index: number}) {
const t = useTheme()
const even = index % 2 === 0
return (
<View
style={[
{paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
a.border_t,
t.atoms.border_contrast_low,
]}>
<Skele.Row style={[a.align_start, a.gap_xs]}>
<Skele.Circle size={TREE_AVI_WIDTH} />
<Skele.Col style={[a.gap_xs]}>
<Skele.Row style={[a.gap_sm]}>
<Skele.Text style={[a.text_md, {width: '20%'}]} />
<Skele.Text blend style={[a.text_md, {width: '30%'}]} />
</Skele.Row>
<Skele.Col>
{even ? (
<>
<Skele.Text blend style={[a.text_md, {width: '100%'}]} />
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
</>
) : (
<Skele.Text blend style={[a.text_md, {width: '60%'}]} />
)}
</Skele.Col>
<PostControlsSkeleton />
</Skele.Col>
</Skele.Row>
</View>
)
}