fix: chat accepts posts
This commit is contained in:
@@ -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} />;
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user