119 lines
3.5 KiB
TypeScript
119 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Attachments under a message bubble.
|
|
*
|
|
* Media (images and videos) tile into a grid and open in a lightbox; links and
|
|
* files keep their own row treatments, since a link card and a photo are read
|
|
* very differently.
|
|
*
|
|
* Media is fetched through the BFF's stable /chat/attachments/{id} URL, which
|
|
* re-checks membership and redirects to a short-lived signed URL. The object
|
|
* itself lives in a private bucket and is never public.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { FileText } from "lucide-react";
|
|
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
|
import { MediaGrid } from "@/components/chat/media-grid";
|
|
import { MediaLightbox } from "@/components/chat/media-lightbox";
|
|
import { attachmentUrl, type Attachment } from "@/lib/chat";
|
|
|
|
function humanSize(bytes?: number | null): string {
|
|
if (!bytes) return "";
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let n = bytes;
|
|
let i = 0;
|
|
while (n >= 1024 && i < units.length - 1) {
|
|
n /= 1024;
|
|
i += 1;
|
|
}
|
|
return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
|
}
|
|
|
|
export function MessageAttachments({
|
|
attachments,
|
|
}: {
|
|
attachments: Attachment[];
|
|
}) {
|
|
const [lightboxAt, setLightboxAt] = useState<number | null>(null);
|
|
|
|
if (!attachments || attachments.length === 0) return null;
|
|
|
|
// Only images and videos tile; everything else keeps its own row.
|
|
const media = attachments.filter(
|
|
(a) => a.kind === "image" || a.kind === "video",
|
|
);
|
|
const rest = attachments.filter(
|
|
(a) => a.kind !== "image" && a.kind !== "video",
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="mt-1 flex flex-col gap-1.5">
|
|
{media.length > 0 && (
|
|
<MediaGrid items={media} onOpen={(i) => setLightboxAt(i)} />
|
|
)}
|
|
|
|
{rest.map((a) => {
|
|
switch (a.kind) {
|
|
case "link":
|
|
return (
|
|
<LinkPreviewCard
|
|
key={a.id}
|
|
compact
|
|
preview={{
|
|
url: a.url ?? "",
|
|
title: a.title ?? undefined,
|
|
description: a.description ?? undefined,
|
|
site_name: a.site_name ?? undefined,
|
|
image_url: a.image_url ?? undefined,
|
|
}}
|
|
/>
|
|
);
|
|
|
|
case "audio":
|
|
return (
|
|
<audio
|
|
key={a.id}
|
|
src={attachmentUrl(a.id)}
|
|
controls
|
|
className="w-56"
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<a
|
|
key={a.id}
|
|
href={attachmentUrl(a.id)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-xs hover:opacity-90 dark:bg-white/10"
|
|
>
|
|
<FileText className="size-4 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{a.file_name ?? "Attachment"}
|
|
</span>
|
|
{a.size_bytes ? (
|
|
<span className="shrink-0 opacity-70">
|
|
{humanSize(a.size_bytes)}
|
|
</span>
|
|
) : null}
|
|
</a>
|
|
);
|
|
}
|
|
})}
|
|
</div>
|
|
|
|
{lightboxAt !== null && (
|
|
<MediaLightbox
|
|
items={media}
|
|
startIndex={lightboxAt}
|
|
onClose={() => setLightboxAt(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|