diff --git a/src/components/dashboard/messenger.tsx b/src/components/dashboard/messenger.tsx index 345bd7a..12bda24 100644 --- a/src/components/dashboard/messenger.tsx +++ b/src/components/dashboard/messenger.tsx @@ -12,8 +12,37 @@ import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui"; -import { useMessengerData, useThread, type Membership, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api"; +import { useMessengerData, useThread, type Membership, type UiAttachment, type UiConversation, type UiMessage, type UiPerson } from "@/lib/messenger-api"; import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket"; +import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api"; + +const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`); + +/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */ +function AttachmentView({ att }: { att: UiAttachment }) { + const getUrl = useDownloadUrl(); + const [url, setUrl] = useState(null); + useEffect(() => { + let alive = true; + getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {}); + return () => { alive = false; }; + }, [att.contentRef, att.mimeType, getUrl]); + + if (isImage(att.mimeType)) { + return url ? ( + // eslint-disable-next-line @next/next/no-img-element + attachment + ) :
Loading image…
; + } + return ( + + + Attachment + {fmtBytes(att.sizeBytes)} + + ); +} const initialsOf = (name: string) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; @@ -164,19 +193,37 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: ( setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200); } + const uploadAttachment = useUploadAttachment(); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + function onDraftChange(v: string) { setDraft(v); const now = Date.now(); if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; } } + async function onPickFile(file: File | undefined) { + if (!file) return; + setUploading(true); + try { setStaged(await uploadAttachment(file)); } + catch (e) { onError((e as Error).message); } + finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; } + } + async function submit() { const text = draft.trim(); - if (!text || sending) return; + if ((!text && !staged) || sending) return; // allow an attachment with no text const parent = replyTo?.id; - setDraft(""); setReplyTo(null); setSending(true); - try { await t.send(text, parent ? { parentInteractionId: parent } : undefined); } - catch (e) { setDraft(text); onError((e as Error).message); } + const att = staged; + setDraft(""); setReplyTo(null); setStaged(null); setSending(true); + try { + await t.send(text, { + ...(parent ? { parentInteractionId: parent } : {}), + ...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}), + }); + } catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); } finally { setSending(false); } } @@ -227,14 +274,27 @@ function ThreadView({ conv, nameOf, onError }: { conv: UiConversation; nameOf: ( )} -