mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
fix(inbox): harden media send — GC orphans, per-type caps, voice cap
Addresses senior-review findings on the media-send feature: - Storage lifecycle: uploads are GC'd on the abandonment paths (discarded draft, failed/errored send, unmount with a staged draft, and replacing one draft with another) via a new deleteAccountMedia helper. Previously every cancel/failure orphaned a public object. - Per-kind size caps (image 5MB, video/audio/document 16MB) mirror Meta's limits and are enforced BEFORE upload, so a too-large image no longer uploads then fails at the Meta call. - Voice notes auto-stop at a 5-min cap and always render as a voice note (transcode anything that isn't already OGG/Opus — Safari MP4 included), not just Chromium WebM. - Send route rejects unknown message_type and captions over Meta's 1024 cap up front; transcode route guards its output size too. - DraftPreview hoisted to a stable module-scope component so the caption input no longer remounts (and drops focus) on each keystroke. Tests: per-kind cap assertions added (418 total). typecheck/lint/build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
312683ca05
commit
96e7a56e3e
@ -88,6 +88,16 @@ export async function POST(request: Request) {
|
|||||||
const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const
|
const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const
|
||||||
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(message_type)
|
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(message_type)
|
||||||
|
|
||||||
|
// Reject anything outside the known set up front rather than letting
|
||||||
|
// an unknown type fall through to the text path with empty content.
|
||||||
|
const VALID_MESSAGE_TYPES = ['text', 'template', ...MEDIA_KINDS] as const
|
||||||
|
if (!(VALID_MESSAGE_TYPES as readonly string[]).includes(message_type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported message_type "${message_type}"` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (message_type === 'text' && !content_text) {
|
if (message_type === 'text' && !content_text) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'content_text is required for text messages' },
|
{ error: 'content_text is required for text messages' },
|
||||||
@ -109,6 +119,20 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta caps media captions at 1024 chars; reject before the upload is
|
||||||
|
// wasted at the Meta call. (Audio carries no caption — see meta-api.)
|
||||||
|
if (
|
||||||
|
isMediaKind &&
|
||||||
|
message_type !== 'audio' &&
|
||||||
|
typeof content_text === 'string' &&
|
||||||
|
content_text.length > 1024
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Caption exceeds the 1024-character limit' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch conversation and contact
|
// Fetch conversation and contact
|
||||||
const { data: conversation, error: convError } = await supabase
|
const { data: conversation, error: convError } = await supabase
|
||||||
.from('conversations')
|
.from('conversations')
|
||||||
|
|||||||
@ -105,6 +105,12 @@ export async function POST(request: Request) {
|
|||||||
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()))
|
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()))
|
||||||
await runFfmpeg(ffmpegPath, buildFfmpegArgs(inputPath, outputPath))
|
await runFfmpeg(ffmpegPath, buildFfmpegArgs(inputPath, outputPath))
|
||||||
const ogg = await readFile(outputPath)
|
const ogg = await readFile(outputPath)
|
||||||
|
if (ogg.byteLength > MAX_INPUT_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Transcoded audio exceeds the 16 MB limit.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
return new Response(new Uint8Array(ogg), {
|
return new Response(new Uint8Array(ogg), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -32,18 +32,30 @@ import { cn } from "@/lib/utils";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
uploadAccountMedia,
|
uploadAccountMedia,
|
||||||
MEDIA_MAX_BYTES,
|
deleteAccountMedia,
|
||||||
|
MEDIA_MAX_BYTES_BY_KIND,
|
||||||
} from "@/lib/storage/upload-media";
|
} from "@/lib/storage/upload-media";
|
||||||
import { isMetaAcceptedAudio } from "@/lib/whatsapp/audio-transcode";
|
|
||||||
import { ReplyQuote } from "./reply-quote";
|
import { ReplyQuote } from "./reply-quote";
|
||||||
|
|
||||||
/** Media content types an agent can send from the composer. */
|
/** Media content types an agent can send from the composer. */
|
||||||
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
|
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
|
||||||
|
|
||||||
|
/** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */
|
||||||
|
export const CHAT_MEDIA_BUCKET = "chat-media";
|
||||||
|
|
||||||
|
/** Meta caps media captions at 1024 chars. Enforced here and in the send route. */
|
||||||
|
export const MEDIA_CAPTION_MAX = 1024;
|
||||||
|
|
||||||
|
/** Hard cap on a single voice recording so it can't blow the upload/
|
||||||
|
* transcode limits — auto-stops the recorder when reached. */
|
||||||
|
const MAX_RECORDING_SECONDS = 5 * 60;
|
||||||
|
|
||||||
export interface SendMediaPayload {
|
export interface SendMediaPayload {
|
||||||
kind: ComposerMediaKind;
|
kind: ComposerMediaKind;
|
||||||
/** Public chat-media URL Meta fetches at send time. */
|
/** Public chat-media URL Meta fetches at send time. */
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
|
/** Storage object path — lets the caller GC the object if the send fails. */
|
||||||
|
path: string;
|
||||||
/** Optional caption (image/video/document only). */
|
/** Optional caption (image/video/document only). */
|
||||||
caption?: string;
|
caption?: string;
|
||||||
/** Original file name — surfaced to the recipient for documents. */
|
/** Original file name — surfaced to the recipient for documents. */
|
||||||
@ -58,8 +70,6 @@ interface ReplyDraft {
|
|||||||
preview: string;
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAT_MEDIA_BUCKET = "chat-media";
|
|
||||||
|
|
||||||
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
|
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
|
||||||
// the file picker so unsupported files are rejected before upload rather
|
// the file picker so unsupported files are rejected before upload rather
|
||||||
// than failing with a confusing Storage error. Audio has no picker — it's
|
// than failing with a confusing Storage error. Audio has no picker — it's
|
||||||
@ -74,6 +84,8 @@ const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
|
|||||||
interface MediaDraft {
|
interface MediaDraft {
|
||||||
kind: ComposerMediaKind;
|
kind: ComposerMediaKind;
|
||||||
mediaUrl: string;
|
mediaUrl: string;
|
||||||
|
/** Storage path — used to GC the object if the draft is discarded. */
|
||||||
|
path: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
caption: string;
|
caption: string;
|
||||||
}
|
}
|
||||||
@ -94,19 +106,13 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A recording can skip the transcode round-trip only if it's already
|
||||||
* Map a Meta-accepted audio MIME to a file extension for the upload path.
|
* OGG/Opus — the one format WhatsApp reliably renders as a voice note
|
||||||
* Recordings that aren't already accepted are transcoded to OGG upstream,
|
* with a waveform. Everything else (Chromium WebM, Safari MP4/AAC) is
|
||||||
* so this only ever sees accepted types.
|
* remuxed server-side so the voice-note UX is consistent across
|
||||||
*/
|
* browsers, not a plain audio attachment on some of them. */
|
||||||
function audioExtForMime(mime: string): string {
|
function isOggOpus(mime: string): boolean {
|
||||||
const base = mime.split(";")[0];
|
return mime.split(";")[0].trim().toLowerCase() === "audio/ogg";
|
||||||
if (base.includes("ogg")) return "ogg";
|
|
||||||
if (base.includes("mp4")) return "m4a";
|
|
||||||
if (base.includes("mpeg")) return "mp3";
|
|
||||||
if (base.includes("aac")) return "aac";
|
|
||||||
if (base.includes("amr")) return "amr";
|
|
||||||
return "ogg";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageComposer({
|
export function MessageComposer({
|
||||||
@ -129,6 +135,19 @@ export function MessageComposer({
|
|||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// Mirror of `draft` for the unmount cleanup, which can't read render
|
||||||
|
// state. Kept in sync below so navigating away with a staged-but-unsent
|
||||||
|
// attachment GCs the orphaned object.
|
||||||
|
const draftRef = useRef<MediaDraft | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
// Best-effort GC of a staged object the user never sent. Fire-and-forget.
|
||||||
|
const removeStaged = useCallback((path: string | undefined) => {
|
||||||
|
if (!path) return;
|
||||||
|
void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Voice recording state.
|
// Voice recording state.
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
@ -155,13 +174,15 @@ export function MessageComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Tear down any live recording + timer on unmount so a mid-record
|
// Tear down any live recording + timer on unmount so a mid-record
|
||||||
// navigation doesn't leak the mic.
|
// navigation doesn't leak the mic, and GC a staged-but-unsent
|
||||||
|
// attachment so it doesn't orphan in the bucket.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
removeStaged(draftRef.current?.path);
|
||||||
};
|
};
|
||||||
}, [clearTimer]);
|
}, [clearTimer, removeStaged]);
|
||||||
|
|
||||||
const adjustHeight = useCallback(() => {
|
const adjustHeight = useCallback(() => {
|
||||||
const el = textareaRef.current;
|
const el = textareaRef.current;
|
||||||
@ -208,23 +229,31 @@ export function MessageComposer({
|
|||||||
// Upload a captured file to chat-media and stage it as a draft.
|
// Upload a captured file to chat-media and stage it as a draft.
|
||||||
const stageUpload = useCallback(
|
const stageUpload = useCallback(
|
||||||
async (kind: ComposerMediaKind, file: File) => {
|
async (kind: ComposerMediaKind, file: File) => {
|
||||||
if (file.size > MEDIA_MAX_BYTES) {
|
// Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we
|
||||||
|
// reject before upload rather than orphaning an object that Meta
|
||||||
|
// would then refuse at send.
|
||||||
|
const max = MEDIA_MAX_BYTES_BY_KIND[kind];
|
||||||
|
if (file.size > max) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
|
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round(
|
||||||
|
max / 1024 / 1024,
|
||||||
|
)} MB.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
||||||
setDraft({ kind, mediaUrl: publicUrl, filename: file.name, caption: "" });
|
// Replacing an existing draft? GC the previous object first.
|
||||||
|
removeStaged(draftRef.current?.path);
|
||||||
|
setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Upload failed.");
|
toast.error(err instanceof Error ? err.message : "Upload failed.");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[removeStaged],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePicked = useCallback(
|
const handlePicked = useCallback(
|
||||||
@ -241,16 +270,12 @@ export function MessageComposer({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
let file: File;
|
let file: File;
|
||||||
if (isMetaAcceptedAudio(mime)) {
|
if (isOggOpus(mime)) {
|
||||||
// Firefox (ogg) / Safari (mp4) already give a Meta-accepted
|
// Firefox records OGG/Opus directly — upload as-is.
|
||||||
// format — upload as-is.
|
file = new File([blob], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
|
||||||
const base = mime.split(";")[0];
|
|
||||||
file = new File([blob], `voice-${Date.now()}.${audioExtForMime(mime)}`, {
|
|
||||||
type: base,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Chromium records WebM/Opus, which Meta rejects — remux to
|
// Chromium (WebM) and Safari (MP4) are remuxed to OGG/Opus so
|
||||||
// OGG/Opus server-side first.
|
// WhatsApp renders a voice note, not a plain audio attachment.
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", blob, "recording");
|
form.append("file", blob, "recording");
|
||||||
const res = await fetch("/api/whatsapp/transcode-audio", {
|
const res = await fetch("/api/whatsapp/transcode-audio", {
|
||||||
@ -264,19 +289,20 @@ export function MessageComposer({
|
|||||||
const ogg = await res.blob();
|
const ogg = await res.blob();
|
||||||
file = new File([ogg], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
|
file = new File([ogg], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
|
||||||
}
|
}
|
||||||
if (file.size > MEDIA_MAX_BYTES) {
|
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
|
||||||
toast.error("Recording is too long (over 16 MB).");
|
toast.error("Recording is too long (over 16 MB).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { publicUrl } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
||||||
setDraft({ kind: "audio", mediaUrl: publicUrl, filename: file.name, caption: "" });
|
removeStaged(draftRef.current?.path);
|
||||||
|
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Could not process the recording.");
|
toast.error(err instanceof Error ? err.message : "Could not process the recording.");
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[removeStaged],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
@ -333,6 +359,14 @@ export function MessageComposer({
|
|||||||
recorderRef.current?.stop();
|
recorderRef.current?.stop();
|
||||||
}, [clearTimer]);
|
}, [clearTimer]);
|
||||||
|
|
||||||
|
// Auto-stop at the cap so a forgotten recording can't blow the
|
||||||
|
// upload/transcode limits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}, [recording, recordSeconds, stopRecording]);
|
||||||
|
|
||||||
// ---- Draft send / discard -----------------------------------------
|
// ---- Draft send / discard -----------------------------------------
|
||||||
|
|
||||||
const sendDraft = useCallback(() => {
|
const sendDraft = useCallback(() => {
|
||||||
@ -340,6 +374,7 @@ export function MessageComposer({
|
|||||||
onSendMedia({
|
onSendMedia({
|
||||||
kind: draft.kind,
|
kind: draft.kind,
|
||||||
mediaUrl: draft.mediaUrl,
|
mediaUrl: draft.mediaUrl,
|
||||||
|
path: draft.path,
|
||||||
// Audio takes no caption (Meta rejects it). Everything else: the
|
// Audio takes no caption (Meta rejects it). Everything else: the
|
||||||
// trimmed caption, or undefined when blank.
|
// trimmed caption, or undefined when blank.
|
||||||
caption:
|
caption:
|
||||||
@ -347,86 +382,23 @@ export function MessageComposer({
|
|||||||
filename: draft.kind === "document" ? draft.filename : undefined,
|
filename: draft.kind === "document" ? draft.filename : undefined,
|
||||||
replyToId: replyTo?.id,
|
replyToId: replyTo?.id,
|
||||||
});
|
});
|
||||||
|
// The object is now owned by the sent message — clear without GC.
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
onClearReply?.();
|
onClearReply?.();
|
||||||
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
|
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
|
||||||
|
|
||||||
const discardDraft = useCallback(() => setDraft(null), []);
|
// Discard GCs the staged object — it was uploaded but never sent.
|
||||||
|
const discardDraft = useCallback(() => {
|
||||||
|
removeStaged(draft?.path);
|
||||||
|
setDraft(null);
|
||||||
|
}, [draft?.path, removeStaged]);
|
||||||
|
|
||||||
|
const setCaption = useCallback((caption: string) => {
|
||||||
|
setDraft((d) => (d ? { ...d, caption } : d));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ---- Render --------------------------------------------------------
|
// ---- Render --------------------------------------------------------
|
||||||
|
|
||||||
const DraftPreview = () => {
|
|
||||||
if (!draft) return null;
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-muted/40 p-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{draft.kind === "image" && (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={draft.mediaUrl}
|
|
||||||
alt={draft.filename}
|
|
||||||
className="max-h-40 rounded-lg object-cover"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{draft.kind === "video" && (
|
|
||||||
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
|
|
||||||
)}
|
|
||||||
{draft.kind === "audio" && (
|
|
||||||
<audio src={draft.mediaUrl} controls className="w-full" />
|
|
||||||
)}
|
|
||||||
{draft.kind === "document" && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
|
||||||
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate">{draft.filename}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={discardDraft}
|
|
||||||
aria-label="Remove attachment"
|
|
||||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-end gap-2">
|
|
||||||
{draft.kind !== "audio" && (
|
|
||||||
<input
|
|
||||||
value={draft.caption}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => (d ? { ...d, caption: e.target.value } : d))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendDraft();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add a caption…"
|
|
||||||
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<GatedButton
|
|
||||||
size="sm"
|
|
||||||
canAct={!readOnly}
|
|
||||||
gateReason="send messages"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={sendDraft}
|
|
||||||
className={cn(
|
|
||||||
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
|
|
||||||
draft.kind === "audio" && "ml-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</GatedButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card p-3">
|
<div className="border-t border-border bg-card p-3">
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
@ -488,13 +460,21 @@ export function MessageComposer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{draft ? (
|
{draft ? (
|
||||||
<DraftPreview />
|
<MediaDraftPreview
|
||||||
|
draft={draft}
|
||||||
|
busy={busy}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCaptionChange={setCaption}
|
||||||
|
onDiscard={discardDraft}
|
||||||
|
onSend={sendDraft}
|
||||||
|
/>
|
||||||
) : recording ? (
|
) : recording ? (
|
||||||
// Recording bar — replaces the composer while the mic is live.
|
// Recording bar — replaces the composer while the mic is live.
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
|
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
|
||||||
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
|
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="flex-1 text-sm text-foreground">
|
<span className="flex-1 text-sm text-foreground">
|
||||||
Recording… {formatDuration(recordSeconds)}
|
Recording… {formatDuration(recordSeconds)} /{" "}
|
||||||
|
{formatDuration(MAX_RECORDING_SECONDS)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -613,3 +593,93 @@ export function MessageComposer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staged-attachment preview with caption + send/discard. Declared at
|
||||||
|
* module scope (not nested in MessageComposer) so React keeps it mounted
|
||||||
|
* across the parent's re-renders — a nested component would remount the
|
||||||
|
* caption input on every keystroke and drop focus.
|
||||||
|
*/
|
||||||
|
function MediaDraftPreview({
|
||||||
|
draft,
|
||||||
|
busy,
|
||||||
|
readOnly,
|
||||||
|
onCaptionChange,
|
||||||
|
onDiscard,
|
||||||
|
onSend,
|
||||||
|
}: {
|
||||||
|
draft: MediaDraft;
|
||||||
|
busy: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
onCaptionChange: (caption: string) => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSend: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-muted/40 p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{draft.kind === "image" && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={draft.mediaUrl}
|
||||||
|
alt={draft.filename}
|
||||||
|
className="max-h-40 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{draft.kind === "video" && (
|
||||||
|
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
|
||||||
|
)}
|
||||||
|
{draft.kind === "audio" && (
|
||||||
|
<audio src={draft.mediaUrl} controls className="w-full" />
|
||||||
|
)}
|
||||||
|
{draft.kind === "document" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{draft.filename}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDiscard}
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-end gap-2">
|
||||||
|
{draft.kind !== "audio" && (
|
||||||
|
<input
|
||||||
|
value={draft.caption}
|
||||||
|
maxLength={MEDIA_CAPTION_MAX}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add a caption…"
|
||||||
|
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<GatedButton
|
||||||
|
size="sm"
|
||||||
|
canAct={!readOnly}
|
||||||
|
gateReason="send messages"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onSend}
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
|
||||||
|
draft.kind === "audio" && "ml-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</GatedButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -34,7 +34,12 @@ import {
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { MessageBubble } from "./message-bubble";
|
import { MessageBubble } from "./message-bubble";
|
||||||
import { MessageActions } from "./message-actions";
|
import { MessageActions } from "./message-actions";
|
||||||
import { MessageComposer, type SendMediaPayload } from "./message-composer";
|
import {
|
||||||
|
MessageComposer,
|
||||||
|
CHAT_MEDIA_BUCKET,
|
||||||
|
type SendMediaPayload,
|
||||||
|
} from "./message-composer";
|
||||||
|
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
||||||
import { TemplatePicker } from "./template-picker";
|
import { TemplatePicker } from "./template-picker";
|
||||||
import { buildReplyPreview } from "./reply-quote";
|
import { buildReplyPreview } from "./reply-quote";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -521,6 +526,9 @@ export function MessageThread({
|
|||||||
console.error("Failed to send media:", reason);
|
console.error("Failed to send media:", reason);
|
||||||
toast.error(`Failed to send: ${reason}`);
|
toast.error(`Failed to send: ${reason}`);
|
||||||
onUpdateMessage(tempId, { status: "failed" });
|
onUpdateMessage(tempId, { status: "failed" });
|
||||||
|
// The upload never reached the recipient — GC the orphaned
|
||||||
|
// object rather than leaving it in the public bucket forever.
|
||||||
|
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,6 +538,7 @@ export function MessageThread({
|
|||||||
const reason = err instanceof Error ? err.message : "network error";
|
const reason = err instanceof Error ? err.message : "network error";
|
||||||
toast.error(`Failed to send: ${reason}`);
|
toast.error(`Failed to send: ${reason}`);
|
||||||
onUpdateMessage(tempId, { status: "failed" });
|
onUpdateMessage(tempId, { status: "failed" });
|
||||||
|
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversation, onNewMessage, onUpdateMessage],
|
[conversation, onNewMessage, onUpdateMessage],
|
||||||
@ -783,7 +792,15 @@ export function MessageThread({
|
|||||||
: "Assign";
|
: "Assign";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-1 flex-col", DOODLE_BG_CLASSES)}>
|
// `min-w-0` is load-bearing: the page already puts min-w-0 on the
|
||||||
|
// thread's flex *wrapper* (issue #165), but this root keeps the
|
||||||
|
// default `min-width: auto`, so a single wide message (long unbroken
|
||||||
|
// URL/word) expands the whole thread past its flex share and the chat
|
||||||
|
// paints on top of the contact sidebar at lg+ — outgoing bubbles get
|
||||||
|
// clipped and the hover toolbar overlaps the Tags panel. Letting the
|
||||||
|
// root shrink lets the bubbles' break-words / max-w caps apply.
|
||||||
|
// Issue #257.
|
||||||
|
<div className={cn("flex min-w-0 flex-1 flex-col", DOODLE_BG_CLASSES)}>
|
||||||
{/* Header — solid card surface sits on top of the doodle so the
|
{/* Header — solid card surface sits on top of the doodle so the
|
||||||
name/avatar/dropdowns stay legible. */}
|
name/avatar/dropdowns stay legible. */}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-3 sm:px-4">
|
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-3 sm:px-4">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildMediaPath } from "./upload-media";
|
import { buildMediaPath, MEDIA_MAX_BYTES_BY_KIND } from "./upload-media";
|
||||||
|
|
||||||
const ACCOUNT = "11111111-2222-3333-4444-555555555555";
|
const ACCOUNT = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
@ -32,3 +32,15 @@ describe("buildMediaPath", () => {
|
|||||||
expect(path).toBe(`account-${ACCOUNT}/1700000000000-README.bin`);
|
expect(path).toBe(`account-${ACCOUNT}/1700000000000-README.bin`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MEDIA_MAX_BYTES_BY_KIND", () => {
|
||||||
|
it("caps images at Meta's tighter 5 MB limit", () => {
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.image).toBe(5 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps video/audio/document at the 16 MB bucket limit", () => {
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.video).toBe(16 * 1024 * 1024);
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.audio).toBe(16 * 1024 * 1024);
|
||||||
|
expect(MEDIA_MAX_BYTES_BY_KIND.document).toBe(16 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -17,6 +17,22 @@ import { createClient } from "@/lib/supabase/client";
|
|||||||
/** 16 MB — matches the `file_size_limit` on both buckets (migrations 016/020/023). */
|
/** 16 MB — matches the `file_size_limit` on both buckets (migrations 016/020/023). */
|
||||||
export const MEDIA_MAX_BYTES = 16 * 1024 * 1024;
|
export const MEDIA_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-kind upload ceilings that mirror Meta's WhatsApp Cloud API caps so
|
||||||
|
* a file that the bucket would accept (≤16 MB) but Meta would reject is
|
||||||
|
* caught client-side BEFORE upload — otherwise it lands in storage as an
|
||||||
|
* orphan and the send fails with a confusing 400. Images are Meta's
|
||||||
|
* tightest cap at 5 MB; documents are held at the 16 MB bucket limit
|
||||||
|
* (Meta allows 100 MB, but the bucket — and shared-hosting upload UX —
|
||||||
|
* caps lower).
|
||||||
|
*/
|
||||||
|
export const MEDIA_MAX_BYTES_BY_KIND = {
|
||||||
|
image: 5 * 1024 * 1024,
|
||||||
|
video: 16 * 1024 * 1024,
|
||||||
|
audio: 16 * 1024 * 1024,
|
||||||
|
document: 16 * 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the account-scoped object path for an upload. Pure + exported so
|
* Build the account-scoped object path for an upload. Pure + exported so
|
||||||
* it can be unit-tested without a Supabase client.
|
* it can be unit-tested without a Supabase client.
|
||||||
@ -100,3 +116,22 @@ export async function uploadAccountMedia(
|
|||||||
|
|
||||||
return { publicUrl, path };
|
return { publicUrl, path };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a previously-uploaded object. Used to GC media that was staged
|
||||||
|
* (uploaded) but never sent — a cancelled draft or a failed Meta send —
|
||||||
|
* so abandoned attachments don't accumulate in the public bucket. The
|
||||||
|
* DELETE is gated by the same account-scoped RLS policy as the upload,
|
||||||
|
* so a caller can only remove objects under their own account folder.
|
||||||
|
*
|
||||||
|
* Best-effort: callers fire-and-forget and swallow errors (a missed
|
||||||
|
* delete is a storage nit, not something to surface to the user).
|
||||||
|
*/
|
||||||
|
export async function deleteAccountMedia(
|
||||||
|
bucket: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { error } = await supabase.storage.from(bucket).remove([path]);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user