import type { ReactNode } from 'react';
import type {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
} from '@atcute/bluesky';
import { DisplayContext, getDisplayRestrictions, type ModerationDecision } from '@atcute/bluesky-moderation';
import type { ResourceUri } from '@atcute/lexicons';
import { parseCanonicalResourceUri } from '@atcute/lexicons/syntax';
import { useQueryClient } from '@tanstack/react-query';
import { getPostRecord } from '#/lib/api/record-casts';
import type { AppModerationCause } from '#/lib/moderation/causes';
import type { Richtext } from '#/lib/rich-text';
import { postUriToTarget } from '#/lib/routes/targets';
import { POST_TOMBSTONE, type Shadow, usePostShadow } from '#/state/cache/post-shadow';
import { useFeedFeedbackContext } from '#/state/feed-feedback';
import { postSourceState } from '#/state/post-source';
import type { PostNumbering } from '#/state/queries/feed-tuner';
import { unstableCacheProfileView } from '#/state/queries/profile';
import { useSession } from '#/state/session';
import { useIsReplyHidden } from '#/state/threadgate-hidden-replies';
import { useOpenComposer } from '#/features/composer/open-composer';
import { useActorStatus } from '#/features/liveNow/use-actor-status';
import { BlockLink } from '#/components/BlockLink';
import { GalleryBleed, maybeApplyGalleryOffsetStyles } from '#/components/images/Gallery';
import { LabelsOnMyPost } from '#/components/moderation/LabelsOnMe';
import { PostRepliedTo } from '#/components/Post/PostRepliedTo';
import { PostContent } from '#/components/PostContent';
import { PostControls } from '#/components/PostControls';
import { DiscoverDebug } from '#/components/PostControls/DiscoverDebug';
import { PostOverflowMenuButton } from '#/components/PostControls/PostOverflowMenuButton';
import * as PostLayout from '#/components/PostLayout';
import { PostMeta } from '#/components/PostMeta';
import { PreviewableUserAvatar } from '#/components/PreviewableUserAvatar';
import * as css from './PostFeedItem.css';
import { PostFeedReason } from './PostFeedReason';
interface FeedItemProps {
record: AppBskyFeedPost.Main;
postNumbering: PostNumbering | undefined;
reason: AppBskyFeedDefs.FeedViewPost['reason'] | undefined;
moderation: ModerationDecision;
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined;
showReplyTo: boolean;
isThreadChild?: boolean;
isThreadParent?: boolean;
feedContext: string | undefined;
reqId: string | undefined;
hideTopBorder?: boolean;
isParentBlocked?: boolean;
isParentNotFound?: boolean;
}
export function PostFeedItem({
post,
record,
postNumbering,
reason,
feedContext,
reqId,
moderation,
parentAuthor,
showReplyTo,
isThreadChild,
isThreadParent,
hideTopBorder,
isParentBlocked,
isParentNotFound,
rootPost,
onShowLess,
}: FeedItemProps & {
post: AppBskyFeedDefs.PostView;
rootPost: AppBskyFeedDefs.PostView;
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void;
}): ReactNode {
const postShadowed = usePostShadow(post);
const richText: Richtext = {
text: record.text,
facets: record.facets,
};
if (postShadowed === POST_TOMBSTONE) {
return null;
}
if (richText && moderation) {
return (
<FeedItemInner
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed}
record={record}
postNumbering={postNumbering}
reason={reason}
feedContext={feedContext}
reqId={reqId}
richText={richText}
parentAuthor={parentAuthor}
showReplyTo={showReplyTo}
moderation={moderation}
isThreadChild={isThreadChild}
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
rootPost={rootPost}
onShowLess={onShowLess}
/>
);
}
return null;
}
function FeedItemInner({
post,
record,
postNumbering,
reason,
feedContext,
reqId,
richText,
moderation,
parentAuthor,
showReplyTo,
isThreadChild,
isThreadParent,
hideTopBorder,
isParentBlocked,
isParentNotFound,
rootPost,
onShowLess,
}: FeedItemProps & {
richText: Richtext;
post: Shadow<AppBskyFeedDefs.PostView>;
rootPost: AppBskyFeedDefs.PostView;
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void;
}): ReactNode {
const queryClient = useQueryClient();
const { openComposer } = useOpenComposer();
const { currentAccount } = useSession();
const target = postUriToTarget(post.uri);
const { sendInteraction, feed } = useFeedFeedbackContext();
let viaRepost: { uri: ResourceUri; cid: string } | undefined;
if (reason?.$type === 'app.bsky.feed.defs#reasonRepost' && reason.uri && reason.cid) {
viaRepost = {
uri: reason.uri,
cid: reason.cid,
};
}
const sourceState = postSourceState({ feed, feedContext, reqId, via: viaRepost });
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,
},
});
};
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);
};
/**
* 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 = rootPost.threadgate
? // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- view defs type `record` as `unknown`; the collection is fixed by the view type
(rootPost.threadgate.record as AppBskyFeedThreadgate.Main)
: undefined;
const { isActive: live } = useActorStatus(post.author);
const isPostHiddenByThreadgate = useIsReplyHidden(post.uri, threadgateRecord);
let additionalPostAlerts: AppModerationCause[] = [];
{
const rootPostUri = getPostRecord(post).reply?.root?.uri || post.uri;
const isControlledByViewer =
rootPostUri && parseCanonicalResourceUri(rootPostUri).repo === currentAccount?.did;
if (isControlledByViewer && isPostHiddenByThreadgate) {
additionalPostAlerts = [
{
type: 'replyHidden',
source: { type: 'user', did: currentAccount?.did },
priority: 6,
},
];
}
}
const galleryOffsetStyles = maybeApplyGalleryOffsetStyles({
additionalCauses: additionalPostAlerts,
moderation,
post,
});
return (
<GalleryBleed>
<BlockLink to={target} state={sourceState} onBeforePress={onBeforePress}>
<PostLayout.Frame hoverable topBorder={!(hideTopBorder || isThreadChild)}>
<div className={css.reasonRow}>
<div className={css.spineSlot}>
{isThreadChild && <PostLayout.Spine className={css.replyLineTop} />}
</div>
<div className={css.reason}>
{reason && <PostFeedReason reason={reason} onOpenReposter={onOpenReposter} />}
</div>
</div>
<PostLayout.Row>
<PostLayout.AvatarColumn>
<PreviewableUserAvatar
size={36}
profile={post.author}
moderation={getDisplayRestrictions(moderation, DisplayContext.ProfileMedia)}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
live={live}
tabIndex={-1}
/>
{isThreadParent && (
<PostLayout.Spine className={live ? css.replyLineParentLive : css.replyLineParent} />
)}
</PostLayout.AvatarColumn>
<PostLayout.ContentColumn style={galleryOffsetStyles?.meta}>
<div className={css.metaSpacing}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postTarget={target}
onOpenAuthor={onOpenAuthor}
/>
<PostOverflowMenuButton
post={post}
record={record}
richText={richText}
feedContext={feedContext}
reqId={reqId}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
/>
</div>
{showReplyTo && (parentAuthor || isParentBlocked || isParentNotFound) && (
<PostRepliedTo
parentAuthor={parentAuthor}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
className={css.repliedTo}
/>
)}
<LabelsOnMyPost post={post} />
<PostContent
additionalCauses={additionalPostAlerts}
displayContext="list"
ignoreMute
moderation={moderation}
onOpenEmbed={onOpenEmbed}
post={post}
postNumbering={postNumbering}
richText={richText}
embedStyle={galleryOffsetStyles?.embed}
/>
<PostControls
post={post}
onPressReply={onPressReply}
feedContext={feedContext}
reqId={reqId}
viaRepost={viaRepost}
/>
</PostLayout.ContentColumn>
<DiscoverDebug feedContext={feedContext} />
</PostLayout.Row>
</PostLayout.Frame>
</BlockLink>
</GalleryBleed>
);
}