import React, {useMemo} from 'react' import {type GestureResponderEvent, Text as RNText, View} from 'react-native' import { type 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 {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 {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 {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 {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' import {Link} from '#/view/com/util/Link' import {formatCount} from '#/view/com/util/numeric/format' import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' 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 {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 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 {type AppModerationCause} from '#/components/Pills' import {PostControls} from '#/components/PostControls' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton' import {WhoCanReply} from '#/components/WhoCanReply' import * as bsky from '#/types/bsky' export function ThreadAnchor({ item, onPostSuccess, threadgateRecord, }: { item: Extract 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 '' } }