import {memo, useCallback, useMemo, useState} from 'react' import {StyleSheet, View} from 'react-native' import { type AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedThreadgate, AtUri, type ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {type ReasonFeedSource} from '#/lib/api/feed/types' 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 {useFeedFeedbackContext} from '#/state/feed-feedback' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import { buildPostSourceKey, setUnstablePostSource, } from '#/state/unstable-post-source' import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' 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} from '#/components/Post/Embed' import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostRepliedTo} from '#/components/Post/PostRepliedTo' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' import {TranslatedPost} from '#/components/Post/Translated' import {PostControls} from '#/components/PostControls' import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' import {RichText} from '#/components/RichText' import {SubtleHover} from '#/components/SubtleHover' import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' import {PostFeedReason} from './PostFeedReason' interface FeedItemProps { record: AppBskyFeedPost.Record reason: | AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | ReasonFeedSource | {[k: string]: unknown; $type: string} | undefined moderation: ModerationDecision parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined showReplyTo: boolean isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean feedContext: string | undefined reqId: string | undefined hideTopBorder?: boolean isParentBlocked?: boolean isParentNotFound?: boolean isCarouselItem?: boolean } export function PostFeedItem({ post, record, reason, feedContext, reqId, moderation, parentAuthor, showReplyTo, isThreadChild, isThreadLastChild, isThreadParent, hideTopBorder, isParentBlocked, isParentNotFound, rootPost, isCarouselItem, onShowLess, }: FeedItemProps & { post: AppBskyFeedDefs.PostView rootPost: AppBskyFeedDefs.PostView onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode { const postShadowed = usePostShadow(post) const richText = useMemo( () => new RichTextAPI({ text: record.text, facets: record.facets, }), [record], ) if (postShadowed === POST_TOMBSTONE) { return null } if (richText && moderation) { return ( ) } return null } let FeedItemInner = ({ post, record, reason, feedContext, reqId, richText, moderation, parentAuthor, showReplyTo, isThreadChild, isThreadLastChild, isThreadParent, hideTopBorder, isParentBlocked, isParentNotFound, isCarouselItem, rootPost, onShowLess, }: FeedItemProps & { richText: RichTextAPI post: Shadow rootPost: AppBskyFeedDefs.PostView onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { const ax = useAnalytics() const queryClient = useQueryClient() const {openComposer} = useOpenComposer() const pal = usePalette('default') const [hover, setHover] = useState(false) const [href] = useMemo(() => { const urip = new AtUri(post.uri) return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey] }, [post.uri, post.author]) const {sendInteraction, feedSourceInfo, feedDescriptor} = useFeedFeedbackContext() const onPressReply = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionReply', feedContext, reqId, }) openComposer({ replyTo: { uri: post.uri, cid: post.cid, text: record.text || '', author: post.author, embed: post.embed, moderation, langs: record.langs, }, logContext: 'PostReply', }) } const onOpenAuthor = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughAuthor', feedContext, reqId, }) ax.metric('post:clickthroughAuthor', { uri: post.uri, authorDid: post.author.did, logContext: 'FeedItem', feedDescriptor, }) } const onOpenReposter = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughReposter', feedContext, reqId, }) } const onOpenEmbed = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughEmbed', feedContext, reqId, }) ax.metric('post:clickthroughEmbed', { uri: post.uri, authorDid: post.author.did, logContext: 'FeedItem', feedDescriptor, }) } const onBeforePress = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughItem', feedContext, reqId, }) ax.metric('post:clickthroughItem', { uri: post.uri, authorDid: post.author.did, logContext: 'FeedItem', feedDescriptor, }) unstableCacheProfileView(queryClient, post.author) setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { feedSourceInfo, post: { post, reason: AppBskyFeedDefs.isReasonRepost(reason) ? reason : undefined, feedContext, reqId, }, }) } const outerStyles = [ styles.outer, { borderColor: pal.colors.border, paddingBottom: isThreadLastChild || (!isThreadChild && !isThreadParent) ? 8 : undefined, borderTopWidth: hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth, }, ] /** * If `post[0]` in this slice is the actual root post (not an orphan thread), * then we may have a threadgate record to reference */ const threadgateRecord = bsky.dangerousIsType( rootPost.threadgate?.record, AppBskyFeedThreadgate.isRecord, ) ? rootPost.threadgate.record : undefined const {isActive: live} = useActorStatus(post.author) const viaRepost = useMemo(() => { if (AppBskyFeedDefs.isReasonRepost(reason) && reason.uri && reason.cid) { return { uri: reason.uri, cid: reason.cid, } } }, [reason]) return ( { setHover(true) }} onPointerLeave={() => { setHover(false) }}> {isThreadChild && ( )} {reason && ( )} {isThreadParent && ( )} {showReplyTo && (parentAuthor || isParentBlocked || isParentNotFound) && ( )} ) } FeedItemInner = memo(FeedItemInner) let PostContent = ({ post, moderation, richText, postEmbed, postAuthor, onOpenEmbed, threadgateRecord, }: { moderation: ModerationDecision richText: RichTextAPI postEmbed: AppBskyFeedDefs.PostView['embed'] postAuthor: AppBskyFeedDefs.PostView['author'] onOpenEmbed: () => void post: AppBskyFeedDefs.PostView threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { const {currentAccount} = useSession() const [limitLines, setLimitLines] = useState( () => countLines(richText.text) >= MAX_POST_LINES, ) const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, }) const additionalPostAlerts: AppModerationCause[] = useMemo(() => { const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) const rootPostUri = bsky.dangerousIsType( post.record, AppBskyFeedPost.isRecord, ) ? post.record?.reply?.root?.uri || post.uri : undefined const isControlledByViewer = rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did return isControlledByViewer && isPostHiddenByThreadgate ? [ { type: 'reply-hidden', source: {type: 'user', did: currentAccount?.did}, priority: 6, }, ] : [] }, [post, currentAccount?.did, threadgateHiddenReplies]) const record = useMemo( () => bsky.validate(post.record, AppBskyFeedPost.validateRecord) ? post.record : undefined, [post], ) const onPressShowMore = useCallback(() => { setLimitLines(false) }, [setLimitLines]) return ( {richText.text ? ( {limitLines && ( )} ) : undefined} {record && ( )} {postEmbed ? ( ) : null} ) } PostContent = memo(PostContent) const styles = StyleSheet.create({ outer: { paddingLeft: 10, paddingRight: 15, cursor: 'pointer', }, replyLine: { width: 2, marginLeft: 'auto', marginRight: 'auto', }, layout: { flexDirection: 'row', marginTop: 1, }, layoutAvi: { paddingLeft: 8, paddingRight: 10, position: 'relative', zIndex: 999, }, layoutContent: { position: 'relative', flex: 1, zIndex: 0, }, alert: { marginTop: 6, marginBottom: 6, }, contentHiderChild: { marginTop: 6, }, embed: { marginBottom: 6, }, translateLink: { marginBottom: 6, }, })