diff --git a/components/chat/message-attachments.tsx b/components/chat/message-attachments.tsx
index c61be257..0ae6a2ce 100644
--- a/components/chat/message-attachments.tsx
+++ b/components/chat/message-attachments.tsx
@@ -18,6 +18,7 @@ import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import { MediaGrid } from "@/components/chat/media-grid";
import { MediaLightbox } from "@/components/chat/media-lightbox";
import { QuoteCard } from "@/components/chat/quote-card";
+import { SharedPostCard } from "@/components/chat/shared-post-card";
import { attachmentUrl, type Attachment } from "@/lib/chat";
function humanSize(bytes?: number | null): string {
@@ -58,6 +59,9 @@ export function MessageAttachments({
{rest.map((a) => {
switch (a.kind) {
+ case "entity":
+ return ;
+
case "quote":
return ;
diff --git a/components/chat/share-to-chat-dialog.tsx b/components/chat/share-to-chat-dialog.tsx
index b3485113..22c2e2e1 100644
--- a/components/chat/share-to-chat-dialog.tsx
+++ b/components/chat/share-to-chat-dialog.tsx
@@ -37,12 +37,16 @@ type SendState = "idle" | "sending" | "sent";
export function ShareToChatDialog({
body = "",
quote,
+ entity,
onClose,
}: {
/** Plain message text. Empty when sending a quote on its own. */
body?: string;
/** A passage from the library, sent as a tappable quotation. */
quote?: QuotePayload;
+ /** A post, sent as a card. Only the reference travels — the BFF resolves
+ * the contents and re-checks visibility for each viewer. */
+ entity?: { type: "post"; id: string };
onClose: () => void;
}) {
const { user } = useAuth();
@@ -111,7 +115,7 @@ export function ShareToChatDialog({
mark(handle, "sending");
try {
const id = conversationId ?? (await chat.openDM(handle)).id;
- await chat.send(id, body, { quote });
+ await chat.send(id, body, { quote, entity });
mark(handle, "sent");
} catch {
mark(handle, "idle");
diff --git a/components/chat/shared-post-card.tsx b/components/chat/shared-post-card.tsx
new file mode 100644
index 00000000..deee33b2
--- /dev/null
+++ b/components/chat/shared-post-card.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+/**
+ * A post shared into a chat, rendered as a card with its author.
+ *
+ * The contents are resolved by the BFF on every read, per viewer — never from
+ * a snapshot frozen at send time. So a post that has since been deleted or
+ * hidden arrives as `entity_unavailable` and renders as a tombstone rather
+ * than quietly showing content the reader is no longer allowed to see.
+ */
+
+import Link from "next/link";
+import { ImageOff } from "lucide-react";
+import { UserAvatar } from "@/components/ui/user-avatar";
+import type { Attachment } from "@/lib/chat";
+
+export function SharedPostCard({ attachment }: { attachment: Attachment }) {
+ const post = attachment.entity;
+
+ if (!post || attachment.entity_unavailable) {
+ return (
+
+
+
+ This post is no longer available
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {post.author_name}
+
+
+ @{post.author_handle}
+
+
+
+
+ {post.image_url && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+ {post.caption && (
+
+ {post.caption}
+
+ )}
+
+ {post.source_title && (
+
+ {post.source_title}
+
+ )}
+
+ );
+}
diff --git a/components/social/post/post-actions-footer.tsx b/components/social/post/post-actions-footer.tsx
index 96695d0d..c47ca439 100644
--- a/components/social/post/post-actions-footer.tsx
+++ b/components/social/post/post-actions-footer.tsx
@@ -39,7 +39,6 @@ import {
type Comment,
type Post,
} from "@/lib/social";
-import { buildPostShareText } from "@/lib/post-share-text";
import { useLongPress } from "@/lib/use-long-press";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import { useAuth } from "@/components/auth/auth-provider";
@@ -65,8 +64,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
// on mobile native-share; sheet mount on desktop) so the Send icon
// can render a spinner while the user waits.
const [sharePending, setSharePending] = useState(false);
- // The post as chat-ready text. Non-null opens the picker.
- const [chatBody, setChatBody] = useState(null);
+ // Opens the chat picker for this post. The post travels as a reference,
+ // not as text — the BFF renders the card and re-checks who may see it.
+ const [sharingToChat, setSharingToChat] = useState(false);
// Optimistic local mirror of post.viewer_has_reshared so the swap
// happens the instant the user taps Reshare. Reconciles to the
// server's value via rollback if the API call fails.
@@ -148,21 +148,10 @@ export function PostActionsFooter({ post }: { post: Post }) {
// Tap → send to a chat. Hold → the platform's own share sheet, for
// everything outside the app. The link text is identical either way; only
// the destination differs.
- async function onSendClick() {
- if (sharePending) return;
- setSharePending(true);
- try {
- const share = await buildPostShareText(post, post.author_handle ?? "");
- if (!share) {
- // No handle means no post URL — fall back to the sheet, which
- // resolves the handle itself and has its own failure path.
- await openShareSheet(post);
- return;
- }
- setChatBody(share.text);
- } finally {
- setSharePending(false);
- }
+ function onSendClick() {
+ // No URL to resolve: sharing in-app sends the post id, so this is
+ // instant where the native sheet has to await the shortener.
+ setSharingToChat(true);
}
async function onSendHold() {
@@ -302,8 +291,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */}
- {chatBody !== null && (
- setChatBody(null)} />
+ {sharingToChat && (
+ setSharingToChat(false)}
+ />
)}
>
);
diff --git a/lib/chat.ts b/lib/chat.ts
index 4d5f13b5..5905ff67 100644
--- a/lib/chat.ts
+++ b/lib/chat.ts
@@ -188,6 +188,25 @@ export type Attachment = {
description?: string | null;
site_name?: string | null;
image_url?: string | null;
+
+ /** Shared entity. `entity` is resolved per viewer on every read, so it can
+ * be absent even though the message still references a post. */
+ entity_type?: "post" | null;
+ entity_id?: string | null;
+ entity?: SharedPost | null;
+ /** The post was deleted, hidden, or is no longer visible to this viewer. */
+ entity_unavailable?: boolean;
+};
+
+/** The post card the BFF resolves for a shared-post attachment. */
+export type SharedPost = {
+ id: string;
+ author_handle: string;
+ author_name: string;
+ author_avatar_url?: string | null;
+ caption: string;
+ image_url?: string | null;
+ source_title?: string;
};
/** How two people in a DM stand with each other. */
@@ -261,6 +280,8 @@ export const chat = {
previewUrl?: string;
attachments?: UploadedAttachment[];
quote?: QuotePayload;
+ /** Share a post as a card. Only the reference — the BFF resolves it. */
+ entity?: { type: "post"; id: string };
},
) =>
call(`/chat/conversations/${conversationId}/messages`, {
@@ -273,6 +294,7 @@ export const chat = {
preview_url: opts?.previewUrl ?? null,
attachments: opts?.attachments ?? [],
quote: opts?.quote ?? null,
+ entity: opts?.entity ?? null,
}),
}),