From 15aca24547d126166276863b9f874a20a7fdc7dd Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 27 May 2025 15:41:14 -0500 Subject: [PATCH] Split up thread post components --- src/alf/atoms.ts | 4 + .../PostThread/components/ReadMore.tsx | 39 +- .../PostThread/components/ThreadAnchor.tsx | 576 ++++++++++++++++++ .../PostThread/components/ThreadPost.tsx | 329 ++++++++++ .../PostThread/components/ThreadReply.tsx | 283 +++++++++ src/screens/PostThread/const.ts | 1 + src/screens/PostThread/index.tsx | 78 ++- 7 files changed, 1265 insertions(+), 45 deletions(-) create mode 100644 src/screens/PostThread/components/ThreadAnchor.tsx create mode 100644 src/screens/PostThread/components/ThreadPost.tsx create mode 100644 src/screens/PostThread/components/ThreadReply.tsx create mode 100644 src/screens/PostThread/const.ts diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 02ad98c5f0..79ec41679a 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1051,4 +1051,8 @@ export const atoms = { transform: [], }, }) as {transform: Exclude}, + + pointer: web({ + cursor: 'pointer', + }), } as const diff --git a/src/screens/PostThread/components/ReadMore.tsx b/src/screens/PostThread/components/ReadMore.tsx index 4df5e45a7a..ec844939ea 100644 --- a/src/screens/PostThread/components/ReadMore.tsx +++ b/src/screens/PostThread/components/ReadMore.tsx @@ -1,9 +1,10 @@ import {View} from 'react-native' -import {msg, Trans, Plural} from '@lingui/macro' +import {msg, Plural,Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {makeProfileLink} from '#/lib/routes/links' -import {type Slice, PostThreadParams} from '#/state/queries/usePostThread' +import {type PostThreadParams,type Slice} from '#/state/queries/usePostThread' +import {TREE_INDENT} from '#/screens/PostThread/const' import {atoms as a, useTheme} from '#/alf' import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlus} from '#/components/icons/CirclePlus' import {Link} from '#/components/Link' @@ -26,11 +27,10 @@ export function ReadMore({ @@ -45,22 +45,21 @@ export function ReadMore({ ) : null return ( - + {treeIndents} - - - + + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { + const t = useTheme() + return ( + + + + This post has been deleted. + + + ) +} + +let PostThreadItemLoaded = ({ + item, + postShadow, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + postShadow: Shadow + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const t = useTheme() + const {_, i18n} = useLingui() + const {openComposer} = useOpenComposer() + const {currentAccount} = useSession() + + const post = item.value.post + const record = item.value.post.record + const moderation = item.moderation + const authorShadow = useProfileShadow(post.author) + const {isActive: live} = useActorStatus(post.author) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + + // TODO + const pal = usePalette('default') + const threadRootUri = record.reply?.root?.uri || post.uri + const isThreadRoot = threadRootUri === post.uri + const authorHref = makeProfileLink(post.author) + const authorTitle = post.author.handle + const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did + + const likesHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + }, [post.uri, post.author]) + const repostsHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') + }, [post.uri, post.author]) + const quotesHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') + }, [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 onlyFollowersCanReply = !!threadgateRecord?.allow?.find( + rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', + ) + const showFollowButton = + currentAccount?.did !== post.author.did && !onlyFollowersCanReply + + 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]) + + return ( + <> + {!isThreadRoot && ( + + + + + + )} + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + + + + + {sanitizeHandle(post.author.handle, '@')} + + + + {showFollowButton && ( + + + + )} + + + + + + {richText?.text ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + {formatCount(i18n, post.repostCount)} + {' '} + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + {formatCount(i18n, post.quoteCount)} + {' '} + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + {formatCount(i18n, post.likeCount)} + {' '} + + + + ) : null} + + ) : null} + + + + + + + ) +} + +function ExpandedPostDetails({ + post, + isThreadAuthor, +}: { + post: Extract['value']['post'] + isThreadAuthor: boolean +}) { + const t = useTheme() + const pal = usePalette('default') + const {_, i18n} = useLingui() + const openLink = useOpenLink() + const langPrefs = useLanguagePrefs() + + const translatorUrl = getTranslatorLink( + post.record?.text || '', + langPrefs.primaryLanguage, + ) + const needsTranslation = useMemo( + () => + Boolean( + langPrefs.primaryLanguage && + !isPostInLanguage(post, [langPrefs.primaryLanguage]), + ), + [post, langPrefs.primaryLanguage], + ) + + const onTranslatePress = React.useCallback( + (e: GestureResponderEvent) => { + e.preventDefault() + openLink(translatorUrl, true) + + if ( + bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + logger.metric('translate', { + sourceLanguages: post.record.langs ?? [], + targetLanguage: langPrefs.primaryLanguage, + textLength: post.record.text.length, + }) + } + + return false + }, + [openLink, translatorUrl, langPrefs, post], + ) + + return ( + + + + + {niceDate(i18n, post.indexedAt)} + + + {needsTranslation && ( + <> + + · + + + + Translate + + + )} + + + ) +} + +function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) { + const t = useTheme() + const {_, i18n} = useLingui() + const control = Prompt.usePromptControl() + + const indexedAt = new Date(post.indexedAt) + const createdAt = bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ? new Date(post.record.createdAt) + : new Date(post.indexedAt) + + // backdated if createdAt is 24 hours or more before indexedAt + const isBackdated = + indexedAt.getTime() - createdAt.getTime() > 24 * 60 * 60 * 1000 + + if (!isBackdated) return null + + const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light + + return ( + <> + + + + + Archived post + + + + This post claims to have been created on{' '} + {niceDate(i18n, createdAt)}, + but was first seen by Bluesky on{' '} + {niceDate(i18n, indexedAt)}. + + + + + Bluesky cannot confirm the authenticity of the claimed date. + + + + {}} /> + + + + ) +} + +function getThreadAuthor( + post: AppBskyFeedDefs.PostView, + record: AppBskyFeedPost.Record, +): string { + if (!record.reply) { + return post.author.did + } + try { + return new AtUri(record.reply.root.uri).host + } catch { + return '' + } +} diff --git a/src/screens/PostThread/components/ThreadPost.tsx b/src/screens/PostThread/components/ThreadPost.tsx new file mode 100644 index 0000000000..a7766f6a78 --- /dev/null +++ b/src/screens/PostThread/components/ThreadPost.tsx @@ -0,0 +1,329 @@ +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 {useActorStatus} from '#/lib/actor-status' +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 Slice} 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 {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' +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 {RichText} from '#/components/RichText' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +export function ThreadPost({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { + const t = useTheme() + return ( + + + + This post has been deleted. + + + ) +} + +let PostThreadItemLoaded = ({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + postShadow: Shadow + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const t = useTheme() + 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]) + + const {isActive: live} = useActorStatus(post.author) + + const showTopBorder = + !item.ui.showParentReplyLine && overrides?.topBorder !== true + + return ( + + + + + + {item.ui.showParentReplyLine && ( + + )} + + + + + {/* If we are in threaded mode, the avatar is rendered in PostMeta */} + + + + {item.ui.showChildReplyLine && ( + + )} + + + + + + + {richText?.text ? ( + + + + ) : undefined} + {limitLines ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + ) +} +PostThreadItemLoaded = memo(PostThreadItemLoaded) + +function SubtleHover({children}: {children: React.ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} diff --git a/src/screens/PostThread/components/ThreadReply.tsx b/src/screens/PostThread/components/ThreadReply.tsx new file mode 100644 index 0000000000..d6565dab75 --- /dev/null +++ b/src/screens/PostThread/components/ThreadReply.tsx @@ -0,0 +1,283 @@ +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 Slice} 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 {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls' +import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostMeta} from '#/view/com/util/PostMeta' +import {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 {RichText} from '#/components/RichText' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import {Text} from '#/components/Typography' + +export function ThreadReply({ + item, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}) { + const postShadow = usePostShadow(item.value.post) + + if (postShadow === POST_TOMBSTONE) { + return + } + + return ( + + ) +} + +function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { + const t = useTheme() + return ( + + + + This post has been deleted. + + + ) +} + +let PostThreadItemLoaded = ({ + item, + postShadow, + overrides, + onPostSuccess, + threadgateRecord, +}: { + item: Extract + postShadow: Shadow + overrides?: { + moderation?: boolean + topBorder?: boolean + } + onPostSuccess?: (data: OnPostSuccessData) => void + threadgateRecord?: AppBskyFeedThreadgate.Record +}): React.ReactNode => { + const t = useTheme() + 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 ( + + {Array.from(Array(item.depth - 1)).map((_, n: number) => ( + + ))} + + + + + + + + + + {richText?.text ? ( + + + + ) : undefined} + {limitLines ? ( + + ) : undefined} + {post.embed && ( + + + + )} + + + + + + + + + ) +} +PostThreadItemLoaded = memo(PostThreadItemLoaded) + +function SubtleHover({children}: {children: React.ReactNode}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + return ( + + + {children} + + ) +} diff --git a/src/screens/PostThread/const.ts b/src/screens/PostThread/const.ts new file mode 100644 index 0000000000..50e3ad3cad --- /dev/null +++ b/src/screens/PostThread/const.ts @@ -0,0 +1 @@ +export const TREE_INDENT = 16 diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index b58254ba21..959ab4d5de 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -16,11 +16,14 @@ import { } from '#/state/queries/usePostThread' import {type OnPostSuccessData} from '#/state/shell/composer' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' -import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem' +// 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 {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' import {ReadMore} from '#/screens/PostThread/components/ReadMore' +import {ThreadAnchor} from '#/screens/PostThread/components/ThreadAnchor' +import {ThreadPost} from '#/screens/PostThread/components/ThreadPost' +import {ThreadReply} from '#/screens/PostThread/components/ThreadReply' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' @@ -142,33 +145,58 @@ export function Inner({uri}: {uri: string | undefined}) { const renderItem = ({item, index}: {item: Slice; index: number}) => { if (item.type === 'threadPost') { - return ( - setDeferParents(false) : undefined}> - 0 - } + overrides={{ + topBorder: index === 0, // && !item.isParentLoading, // TODO + }} onPostSuccess={optimisticOnPostReply} - hideTopBorder={index === 0} // && !item.isParentLoading} // TODO /> - - ) + ) + } else if (item.depth === 0) { + return ( + setDeferParents(false) : undefined}> + + + ) + } else { + if (treeViewEnabled) { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } else { + return ( + 0, + }} + onPostSuccess={optimisticOnPostReply} + /> + ) + } + } } else if (item.type === 'readMore') { return } else if (item.type === 'threadPostBlocked') {