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,
},
})