import {memo, useCallback, useMemo, useState} from 'react' import { type GestureResponderEvent, StyleSheet, Text as RNText, View, } from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, type AppBskyFeedThreadgate, AtUri, type ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {msg, Plural, 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 {useOpenLink} from '#/lib/hooks/useOpenLink' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {countLines} from '#/lib/strings/helpers' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers' import {logger} from '#/logger' import { POST_TOMBSTONE, type Shadow, usePostShadow, } from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' import {useLanguagePrefs} from '#/state/preferences' import {type ThreadPost} from '#/state/queries/post-thread' import {useSession} from '#/state/session' import {type OnPostSuccessData} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {type PostSource} from '#/state/unstable-post-source' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {Link, TextLink} from '#/view/com/util/Link' import {formatCount} from '#/view/com/util/numeric/format' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme} from '#/alf' import {colors} from '#/components/Admonition' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {InlineLinkText} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' 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 {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {PostControls} from '#/components/PostControls' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import {Text} from '#/components/Typography' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import * as bsky from '#/types/bsky' export function PostThreadItem({ post, record, moderation, treeView, depth, prevPost, nextPost, isHighlightedPost, hasMore, showChildReplyLine, showParentReplyLine, hasPrecedingItem, overrideBlur, onPostReply, onPostSuccess, hideTopBorder, threadgateRecord, anchorPostSource, }: { post: AppBskyFeedDefs.PostView record: AppBskyFeedPost.Record moderation: ModerationDecision | undefined treeView: boolean depth: number prevPost: ThreadPost | undefined nextPost: ThreadPost | undefined isHighlightedPost?: boolean hasMore?: boolean showChildReplyLine?: boolean showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean onPostReply: (postUri: string | undefined) => void onPostSuccess?: (data: OnPostSuccessData) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record anchorPostSource?: PostSource }) { const postShadowed = usePostShadow(post) const richText = useMemo( () => new RichTextAPI({ text: record.text, facets: record.facets, }), [record], ) if (postShadowed === POST_TOMBSTONE) { return } if (richText && moderation) { return ( ) } return null } function PostThreadItemDeleted({hideTopBorder}: {hideTopBorder?: boolean}) { const t = useTheme() return ( This post has been deleted. ) } let PostThreadItemLoaded = ({ post, record, richText, moderation, treeView, depth, prevPost, nextPost, isHighlightedPost, hasMore, showChildReplyLine, showParentReplyLine, hasPrecedingItem, overrideBlur, onPostReply, onPostSuccess, hideTopBorder, threadgateRecord, anchorPostSource, }: { post: Shadow record: AppBskyFeedPost.Record richText: RichTextAPI moderation: ModerationDecision treeView: boolean depth: number prevPost: ThreadPost | undefined nextPost: ThreadPost | undefined isHighlightedPost?: boolean hasMore?: boolean showChildReplyLine?: boolean showParentReplyLine?: boolean hasPrecedingItem: boolean overrideBlur: boolean onPostReply: (postUri: string | undefined) => void onPostSuccess?: (data: OnPostSuccessData) => void hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record anchorPostSource?: PostSource }): React.ReactNode => { const {currentAccount, hasSession} = useSession() const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession) const t = useTheme() const pal = usePalette('default') const {_, i18n} = useLingui() const langPrefs = useLanguagePrefs() const {openComposer} = useOpenComposer() const [limitLines, setLimitLines] = useState( () => countLines(richText?.text) >= MAX_POST_LINES, ) const shadowedPostAuthor = useProfileShadow(post.author) const rootUri = 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 itemTitle = _(msg`Post by ${post.author.handle}`) const authorHref = makeProfileLink(post.author) const authorTitle = post.author.handle const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did const likesHref = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') }, [post.uri, post.author]) const likesTitle = _(msg`Likes on this post`) const repostsHref = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) const repostsTitle = _(msg`Reposts of this post`) const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, }) const additionalPostAlerts: AppModerationCause[] = useMemo(() => { const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did return isControlledByViewer && isPostHiddenByThreadgate ? [ { type: 'reply-hidden', source: {type: 'user', did: currentAccount?.did}, priority: 6, }, ] : [] }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri]) const quotesHref = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') }, [post.uri, post.author]) const quotesTitle = _(msg`Quotes of this post`) const onlyFollowersCanReply = !!threadgateRecord?.allow?.find( rule => rule.$type === 'app.bsky.feed.threadgate#followerRule', ) const showFollowButton = currentAccount?.did !== post.author.did && !onlyFollowersCanReply const translatorUrl = getTranslatorLink( record?.text || '', langPrefs.primaryLanguage, ) const needsTranslation = useMemo( () => Boolean( langPrefs.primaryLanguage && !isPostInLanguage(post, [langPrefs.primaryLanguage]), ), [post, langPrefs.primaryLanguage], ) const onPressReply = () => { if (anchorPostSource && isHighlightedPost) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionReply', feedContext: anchorPostSource.post.feedContext, reqId: anchorPostSource.post.reqId, }) } openComposer({ replyTo: { uri: post.uri, cid: post.cid, text: record.text, author: post.author, embed: post.embed, moderation, }, onPost: onPostReply, onPostSuccess: onPostSuccess, }) } const onOpenAuthor = () => { if (anchorPostSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughAuthor', feedContext: anchorPostSource.post.feedContext, reqId: anchorPostSource.post.reqId, }) } } const onOpenEmbed = () => { if (anchorPostSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughEmbed', feedContext: anchorPostSource.post.feedContext, reqId: anchorPostSource.post.reqId, }) } } const onPressShowMore = useCallback(() => { setLimitLines(false) }, [setLimitLines]) const {isActive: live} = useActorStatus(post.author) const reason = anchorPostSource?.post.reason const viaRepost = useMemo(() => { if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { return { uri: reason.uri, cid: reason.cid, } } }, [reason]) if (!record) { return } if (isHighlightedPost) { return ( <> {rootUri !== post.uri && ( )} {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} ) } else { const isThreadedChild = treeView && depth > 0 const isThreadedChildAdjacentTop = isThreadedChild && prevPost?.ctx.depth === depth && depth !== 1 const isThreadedChildAdjacentBot = isThreadedChild && nextPost?.ctx.depth === depth return ( {!isThreadedChild && showParentReplyLine && ( )} {/* If we are in threaded mode, the avatar is rendered in PostMeta */} {!isThreadedChild && ( {showChildReplyLine && ( )} )} {richText?.text ? ( ) : undefined} {limitLines ? ( ) : undefined} {post.embed && ( )} {hasMore ? ( More ) : undefined} ) } } PostThreadItemLoaded = memo(PostThreadItemLoaded) function PostOuterWrapper({ post, treeView, depth, showParentReplyLine, hasPrecedingItem, hideTopBorder, children, }: React.PropsWithChildren<{ post: AppBskyFeedDefs.PostView treeView: boolean depth: number showParentReplyLine: boolean hasPrecedingItem: boolean hideTopBorder?: boolean }>) { const t = useTheme() const { state: hover, onIn: onHoverIn, onOut: onHoverOut, } = useInteractionState() if (treeView && depth > 0) { return ( {Array.from(Array(depth - 1)).map((_, n: number) => ( ))} {children} ) } return ( {children} ) } function ExpandedPostDetails({ post, isThreadAuthor, needsTranslation, translatorUrl, }: { post: AppBskyFeedDefs.PostView isThreadAuthor: boolean needsTranslation: boolean translatorUrl: string }) { const t = useTheme() const pal = usePalette('default') const {_, i18n} = useLingui() const openLink = useOpenLink() const isRootPost = !('reply' in post.record) const langPrefs = useLanguagePrefs() const onTranslatePress = 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, }, {statsig: false}, ) } return false }, [openLink, translatorUrl, langPrefs, post], ) return ( {niceDate(i18n, post.indexedAt)} {isRootPost && ( )} {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 '' } } const styles = StyleSheet.create({ outer: { borderTopWidth: StyleSheet.hairlineWidth, paddingLeft: 8, }, noTopBorder: { borderTopWidth: 0, }, meta: { flexDirection: 'row', paddingVertical: 2, }, metaExpandedLine1: { paddingVertical: 0, }, loadMore: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 4, paddingHorizontal: 20, }, replyLine: { width: 2, marginLeft: 'auto', marginRight: 'auto', }, cursor: { // @ts-ignore web only cursor: 'pointer', }, })