diff options
Diffstat (limited to 'src/screens/PostThread/components/ThreadItemTreePost.tsx')
-rw-r--r-- | src/screens/PostThread/components/ThreadItemTreePost.tsx | 456 |
1 files changed, 456 insertions, 0 deletions
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx new file mode 100644 index 000000000..d86d2ef6f --- /dev/null +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -0,0 +1,456 @@ +import React, {memo, useMemo} from 'react' +import {View} from 'react-native' +import { + type AppBskyFeedDefs, + type AppBskyFeedThreadgate, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {MAX_POST_LINES} from '#/lib/constants' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' +import {usePalette} from '#/lib/hooks/usePalette' +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 {TextLink} from '#/view/com/util/Link' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +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 {useInteractionState} from '#/components/hooks/useInteractionState' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {PostHider} from '#/components/moderation/PostHider' +import {type AppModerationCause} from '#/components/Pills' +import {PostControls} from '#/components/PostControls' +import {RichText} from '#/components/RichText' +import * as Skele from '#/components/Skeleton' +import {SubtleWebHover} from '#/components/SubtleWebHover' +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 ( + <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> + ) + }, +) + +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 && a.pt_lg, + !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, {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 pal = usePalette('default') + const {_} = useLingui() + 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] = React.useState( + () => countLines(richText?.text) >= MAX_POST_LINES, + ) + const threadRootUri = record.reply?.root?.uri || post.uri + const postHref = React.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[] = React.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 = React.useCallback(() => { + openComposer({ + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + embed: post.embed, + moderation, + }, + onPostSuccess: onPostSuccess, + }) + }, [openComposer, post, record, onPostSuccess, moderation]) + + const onPressShowMore = React.useCallback(() => { + setLimitLines(false) + }, [setLimitLines]) + + return ( + <ThreadItemTreePostOuterWrapper item={item}> + <SubtleHover> + <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_2xs]} + showAvatar + /> + <View style={[a.flex_row]}> + <ThreadItemTreeReplyChildReplyLine item={item} /> + <View style={[a.flex_1]}> + <LabelsOnMyPost post={post} style={[a.pb_2xs]} /> + <PostAlerts + modui={moderation.ui('contentList')} + style={[a.pb_2xs]} + additionalCauses={additionalPostAlerts} + /> + {richText?.text ? ( + <View> + <RichText + enableTags + value={richText} + style={[a.flex_1, a.text_md]} + numberOfLines={limitLines ? MAX_POST_LINES : undefined} + authorHandle={post.author.handle} + shouldProxyLinks={true} + /> + </View> + ) : undefined} + {limitLines ? ( + <TextLink + text={_(msg`Show More`)} + style={pal.link} + onPress={onPressShowMore} + href="#" + /> + ) : undefined} + {post.embed && ( + <View style={[a.pb_xs]}> + <PostEmbeds + embed={post.embed} + moderation={moderation} + viewContext={PostEmbedViewContext.Feed} + /> + </View> + )} + <PostControls + post={postShadow} + record={record} + richText={richText} + onPressReply={onPressReply} + logContext="PostThreadItem" + threadgateRecord={threadgateRecord} + /> + </View> + </View> + </View> + </ThreadItemTreePostInnerWrapper> + </PostHider> + </SubtleHover> + </ThreadItemTreePostOuterWrapper> + ) +}) + +function SubtleHover({children}: {children: React.ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + <View + onPointerEnter={onHoverIn} + onPointerLeave={onHoverOut} + style={[a.flex_1, a.pointer]}> + <SubtleWebHover 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.gap_md, + a.border_t, + t.atoms.border_contrast_low, + ]}> + <Skele.Row style={[a.align_start, a.gap_md]}> + <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> + + <Skele.Row style={[a.justify_between, a.pt_xs]}> + <Skele.Pill blend size={16} /> + <Skele.Pill blend size={16} /> + <Skele.Pill blend size={16} /> + <Skele.Circle blend size={16} /> + <View /> + </Skele.Row> + </Skele.Col> + </Skele.Row> + </View> + ) +} |