import {memo, useCallback, useMemo} from 'react' import {type GestureResponderEvent, Text as RNText, View} from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, type AppBskyFeedThreadgate, AtUri, 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 {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useTranslate} from '#/lib/hooks/useTranslate' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' 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 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 {type PostSource} from '#/state/unstable-post-source' import {formatCount} from '#/view/com/util/numeric/format' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' import { LINEAR_AVI_WIDTH, OUTER_SPACE, REPLY_LINE_WIDTH, } from '#/screens/PostThread/const' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {colors} from '#/components/Admonition' import {Button} from '#/components/Button' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {InlineLinkText, Link} from '#/components/Link' import {LoggedOutCTA} from '#/components/LoggedOutCTA' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {PostControls} from '#/components/PostControls' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Skele from '#/components/Skeleton' import {Text} from '#/components/Typography' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import * as bsky from '#/types/bsky' export function ThreadItemAnchor({ item, onPostSuccess, threadgateRecord, postSource, }: { item: Extract onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record postSource?: PostSource }) { const postShadow = usePostShadow(item.value.post) const threadRootUri = item.value.post.record.reply?.root?.uri || item.uri const isRoot = threadRootUri === item.uri if (postShadow === POST_TOMBSTONE) { return } return ( ) } function ThreadItemAnchorDeleted({isRoot}: {isRoot: boolean}) { const t = useTheme() return ( <> Post has been deleted ) } function ThreadItemAnchorParentReplyLine({isRoot}: {isRoot: boolean}) { const t = useTheme() return !isRoot ? ( ) : null } const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ item, isRoot, postShadow, onPostSuccess, threadgateRecord, postSource, }: { item: Extract isRoot: boolean postShadow: Shadow onPostSuccess?: (data: OnPostSuccessData) => void threadgateRecord?: AppBskyFeedThreadgate.Record postSource?: PostSource }) { const t = useTheme() const {_, i18n} = useLingui() const {openComposer} = useOpenComposer() const {currentAccount, hasSession} = useSession() const {gtTablet} = useBreakpoints() const feedFeedback = useFeedFeedback(postSource?.feed, hasSession) const post = postShadow 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], ) const threadRootUri = record.reply?.root?.uri || post.uri const authorHref = makeProfileLink(post.author) 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 repostsHref = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) const quotesHref = 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[] = 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 viaRepost = useMemo(() => { const reason = postSource?.post.reason if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { return { uri: reason.uri, cid: reason.cid, } } }, [postSource]) const onPressReply = useCallback(() => { openComposer({ replyTo: { uri: post.uri, cid: post.cid, text: record.text, author: post.author, embed: post.embed, moderation, langs: record.langs, }, onPostSuccess: onPostSuccess, }) if (postSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionReply', feedContext: postSource.post.feedContext, reqId: postSource.post.reqId, }) } }, [ openComposer, post, record, onPostSuccess, moderation, postSource, feedFeedback, ]) const onOpenAuthor = () => { if (postSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughAuthor', feedContext: postSource.post.feedContext, reqId: postSource.post.reqId, }) } } const onOpenEmbed = () => { if (postSource) { feedFeedback.sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughEmbed', feedContext: postSource.post.feedContext, reqId: postSource.post.reqId, }) } } return ( <> {/* Show CTA for logged-out visitors - hide on desktop and check gate */} {!gtTablet && } {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 {_, i18n} = useLingui() const translate = useTranslate() const isRootPost = !('reply' in post.record) const langPrefs = useLanguagePrefs() const needsTranslation = useMemo( () => Boolean( langPrefs.primaryLanguage && !isPostInLanguage(post, [langPrefs.primaryLanguage]), ), [post, langPrefs.primaryLanguage], ) const onTranslatePress = useCallback( (e: GestureResponderEvent) => { e.preventDefault() translate(post.record.text || '', langPrefs.primaryLanguage) if ( bsky.dangerousIsType( post.record, AppBskyFeedPost.isRecord, ) ) { logger.metric('translate', { sourceLanguages: post.record.langs ?? [], targetLanguage: langPrefs.primaryLanguage, textLength: post.record.text.length, }) } return false }, [translate, 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 '' } } export function ThreadItemAnchorSkeleton() { return ( ) }