fix: chat accepts posts

This commit is contained in:
gosangam
2026-08-29 16:26:53 +05:30
parent 76cd106ef7
commit 3e6a1c6f39
5 changed files with 118 additions and 21 deletions
+4
View File
@@ -18,6 +18,7 @@ import { LinkPreviewCard } from "@/components/chat/link-preview-card";
import { MediaGrid } from "@/components/chat/media-grid"; import { MediaGrid } from "@/components/chat/media-grid";
import { MediaLightbox } from "@/components/chat/media-lightbox"; import { MediaLightbox } from "@/components/chat/media-lightbox";
import { QuoteCard } from "@/components/chat/quote-card"; import { QuoteCard } from "@/components/chat/quote-card";
import { SharedPostCard } from "@/components/chat/shared-post-card";
import { attachmentUrl, type Attachment } from "@/lib/chat"; import { attachmentUrl, type Attachment } from "@/lib/chat";
function humanSize(bytes?: number | null): string { function humanSize(bytes?: number | null): string {
@@ -58,6 +59,9 @@ export function MessageAttachments({
{rest.map((a) => { {rest.map((a) => {
switch (a.kind) { switch (a.kind) {
case "entity":
return <SharedPostCard key={a.id} attachment={a} />;
case "quote": case "quote":
return <QuoteCard key={a.id} attachment={a} />; return <QuoteCard key={a.id} attachment={a} />;
+5 -1
View File
@@ -37,12 +37,16 @@ type SendState = "idle" | "sending" | "sent";
export function ShareToChatDialog({ export function ShareToChatDialog({
body = "", body = "",
quote, quote,
entity,
onClose, onClose,
}: { }: {
/** Plain message text. Empty when sending a quote on its own. */ /** Plain message text. Empty when sending a quote on its own. */
body?: string; body?: string;
/** A passage from the library, sent as a tappable quotation. */ /** A passage from the library, sent as a tappable quotation. */
quote?: QuotePayload; 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; onClose: () => void;
}) { }) {
const { user } = useAuth(); const { user } = useAuth();
@@ -111,7 +115,7 @@ export function ShareToChatDialog({
mark(handle, "sending"); mark(handle, "sending");
try { try {
const id = conversationId ?? (await chat.openDM(handle)).id; const id = conversationId ?? (await chat.openDM(handle)).id;
await chat.send(id, body, { quote }); await chat.send(id, body, { quote, entity });
mark(handle, "sent"); mark(handle, "sent");
} catch { } catch {
mark(handle, "idle"); mark(handle, "idle");
+75
View File
@@ -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 (
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">
<ImageOff className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-sm text-muted-foreground">
This post is no longer available
</span>
</div>
);
}
return (
<Link
href={`/u/${encodeURIComponent(post.author_handle)}/posts#post-${post.id}`}
className="mt-1 block overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/40"
>
<span className="flex items-center gap-2 px-3 py-2">
<UserAvatar
src={post.author_avatar_url ?? null}
name={post.author_name}
className="size-6 shrink-0"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-foreground">
{post.author_name}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
@{post.author_handle}
</span>
</span>
</span>
{post.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={post.image_url}
alt=""
loading="lazy"
className="aspect-square w-full object-cover"
/>
)}
{post.caption && (
<span className="block px-3 py-2 text-sm leading-relaxed text-foreground line-clamp-3">
{post.caption}
</span>
)}
{post.source_title && (
<span className="block truncate px-3 pb-2 text-xs text-muted-foreground">
{post.source_title}
</span>
)}
</Link>
);
}
+12 -20
View File
@@ -39,7 +39,6 @@ import {
type Comment, type Comment,
type Post, type Post,
} from "@/lib/social"; } from "@/lib/social";
import { buildPostShareText } from "@/lib/post-share-text";
import { useLongPress } from "@/lib/use-long-press"; import { useLongPress } from "@/lib/use-long-press";
import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog"; import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog";
import { useAuth } from "@/components/auth/auth-provider"; 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 // on mobile native-share; sheet mount on desktop) so the Send icon
// can render a spinner while the user waits. // can render a spinner while the user waits.
const [sharePending, setSharePending] = useState(false); const [sharePending, setSharePending] = useState(false);
// The post as chat-ready text. Non-null opens the picker. // Opens the chat picker for this post. The post travels as a reference,
const [chatBody, setChatBody] = useState<string | null>(null); // 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 // Optimistic local mirror of post.viewer_has_reshared so the swap
// happens the instant the user taps Reshare. Reconciles to the // happens the instant the user taps Reshare. Reconciles to the
// server's value via rollback if the API call fails. // 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 // 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 // everything outside the app. The link text is identical either way; only
// the destination differs. // the destination differs.
async function onSendClick() { function onSendClick() {
if (sharePending) return; // No URL to resolve: sharing in-app sends the post id, so this is
setSharePending(true); // instant where the native sheet has to await the shortener.
try { setSharingToChat(true);
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);
}
} }
async function onSendHold() { async function onSendHold() {
@@ -302,8 +291,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
no per-card instance lives here. The provider's no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */} openShareSheet swaps the active post under the hood. */}
{chatBody !== null && ( {sharingToChat && (
<ShareToChatDialog body={chatBody} onClose={() => setChatBody(null)} /> <ShareToChatDialog
entity={{ type: "post", id: post.id }}
onClose={() => setSharingToChat(false)}
/>
)} )}
</> </>
); );
+22
View File
@@ -188,6 +188,25 @@ export type Attachment = {
description?: string | null; description?: string | null;
site_name?: string | null; site_name?: string | null;
image_url?: 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. */ /** How two people in a DM stand with each other. */
@@ -261,6 +280,8 @@ export const chat = {
previewUrl?: string; previewUrl?: string;
attachments?: UploadedAttachment[]; attachments?: UploadedAttachment[];
quote?: QuotePayload; quote?: QuotePayload;
/** Share a post as a card. Only the reference — the BFF resolves it. */
entity?: { type: "post"; id: string };
}, },
) => ) =>
call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, { call<ChatMessage>(`/chat/conversations/${conversationId}/messages`, {
@@ -273,6 +294,7 @@ export const chat = {
preview_url: opts?.previewUrl ?? null, preview_url: opts?.previewUrl ?? null,
attachments: opts?.attachments ?? [], attachments: opts?.attachments ?? [],
quote: opts?.quote ?? null, quote: opts?.quote ?? null,
entity: opts?.entity ?? null,
}), }),
}), }),