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 { FontAwesomeIcon, type FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {useActorStatus} from '#/lib/actor-status' import {isReasonFeedSource, 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 {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {countLines} from '#/lib/strings/helpers' import {s} from '#/lib/styles' 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 {FeedNameText} from '#/view/com/util/FeedInfoText' import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {Text} from '#/view/com/util/text/Text' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' 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 {PostControls} from '#/components/PostControls' import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' 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 } export function PostFeedItem({ post, record, reason, feedContext, reqId, moderation, parentAuthor, showReplyTo, isThreadChild, isThreadLastChild, isThreadParent, hideTopBorder, isParentBlocked, isParentNotFound, rootPost, 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, rootPost, onShowLess, }: FeedItemProps & { richText: RichTextAPI post: Shadow rootPost: AppBskyFeedDefs.PostView onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void }): React.ReactNode => { const queryClient = useQueryClient() const {openComposer} = useOpenComposer() const pal = usePalette('default') const {_} = useLingui() const [hover, setHover] = useState(false) const href = useMemo(() => { const urip = new AtUri(post.uri) return makeProfileLink(post.author, 'post', urip.rkey) }, [post.uri, post.author]) const {sendInteraction, 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, }, }) } const onOpenAuthor = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughAuthor', feedContext, reqId, }) } 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, }) } const onBeforePress = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#clickthroughItem', feedContext, reqId, }) unstableCacheProfileView(queryClient, post.author) setUnstablePostSource(buildPostSourceKey(post.uri, post.author.handle), { feed: feedDescriptor, 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, }, ] const {currentAccount} = useSession() const isOwner = AppBskyFeedDefs.isReasonRepost(reason) && reason.by.did === currentAccount?.did /** * 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 && ( )} {isReasonFeedSource(reason) ? ( From{' '} ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( {isOwner ? ( Reposted by you ) : ( Reposted by{' '} {sanitizeDisplayName( reason.by.displayName || sanitizeHandle(reason.by.handle), moderation.ui('displayName'), )} } href={makeProfileLink(reason.by)} onBeforePress={onOpenReposter} /> )} ) : AppBskyFeedDefs.isReasonPin(reason) ? ( Pinned ) : null} {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 pal = usePalette('default') const {_} = useLingui() 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 onPressShowMore = useCallback(() => { setLimitLines(false) }, [setLimitLines]) return ( {richText.text ? ( ) : undefined} {limitLines ? ( ) : undefined} {postEmbed ? ( ) : null} ) } PostContent = memo(PostContent) function ReplyToLabel({ profile, blocked, notFound, }: { profile: AppBskyActorDefs.ProfileViewBasic | undefined blocked?: boolean notFound?: boolean }) { const pal = usePalette('default') const {currentAccount} = useSession() let label if (blocked) { label = Reply to a blocked post } else if (notFound) { label = Reply to a post } else if (profile != null) { const isMe = profile.did === currentAccount?.did if (isMe) { label = Reply to you } else { label = ( Reply to{' '} {profile.displayName ? sanitizeDisplayName(profile.displayName) : sanitizeHandle(profile.handle)} } /> ) } } if (!label) { // Should not happen. return null } return ( {label} ) } const styles = StyleSheet.create({ outer: { paddingLeft: 10, paddingRight: 15, // @ts-ignore web only -prf cursor: 'pointer', }, replyLine: { width: 2, marginLeft: 'auto', marginRight: 'auto', }, includeReason: { flexDirection: 'row', alignItems: 'center', marginTop: 2, marginBottom: 2, marginLeft: -16, }, 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, }, postTextContainer: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', paddingBottom: 2, overflow: 'hidden', }, contentHiderChild: { marginTop: 6, }, embed: { marginBottom: 6, }, translateLink: { marginBottom: 6, }, })