wacrm/src/components/inbox/message-composer.tsx
Arnas Donauskas ac9fc8dfc9
feat(inbox): graduate chat media send to GA (remove beta flag)
Photo/video/document/voice send has been validated in production, so it's
now available to every account. Removes the 'chat_media' beta gate:

- Composer always shows the attach menu + voice recorder (no
  useBetaFeature check).
- Send route no longer 403s media kinds for non-opted-in accounts.
- Deletes the now-unused beta-features helper, the useBetaFeature hook,
  and their test (added solely for this rollout; no other consumer).

The profiles.beta_features column (migration 011) is left in place for
future betas. typecheck/lint/build green; 411 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:38:54 +03:00

664 lines
23 KiB
TypeScript

"use client";
import {
useState,
useRef,
useCallback,
useEffect,
KeyboardEvent,
} from "react";
import {
Send,
LayoutTemplate,
Paperclip,
Image as ImageIcon,
Video,
FileText,
Mic,
Square,
X,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
uploadAccountMedia,
deleteAccountMedia,
MEDIA_MAX_BYTES_BY_KIND,
} from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote";
/** Media content types an agent can send from the composer. */
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 {
kind: ComposerMediaKind;
/** Public chat-media URL Meta fetches at send time. */
mediaUrl: string;
/** Storage object path — lets the caller GC the object if the send fails. */
path: string;
/** Optional caption (image/video/document only). */
caption?: string;
/** Original file name — surfaced to the recipient for documents. */
filename?: string;
replyToId?: string;
}
interface ReplyDraft {
/** Internal UUID of the message being replied to — sent back through onSend. */
id: string;
authorLabel: string;
preview: string;
}
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
// the file picker so unsupported files are rejected before upload rather
// than failing with a confusing Storage error. Audio has no picker — it's
// captured via the recorder.
const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
interface MediaDraft {
kind: ComposerMediaKind;
mediaUrl: string;
/** Storage path — used to GC the object if the draft is discarded. */
path: string;
filename: string;
caption: string;
}
interface MessageComposerProps {
conversationId: string;
sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void;
onOpenTemplates: () => void;
replyTo?: ReplyDraft | null;
onClearReply?: () => void;
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/** Worker that encodes mic input to Ogg/Opus entirely in the browser
* (vendored from opus-recorder into /public). Recording client-side in a
* Meta-accepted format means no server ffmpeg / transcode step. */
const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js";
export function MessageComposer({
conversationId,
sessionExpired,
onSend,
onSendMedia,
onOpenTemplates,
replyTo,
onClearReply,
}: MessageComposerProps) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent
// attachment; `busy` covers the upload/transcode window.
const [draft, setDraft] = useState<MediaDraft | null>(null);
const [busy, setBusy] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = 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. The recorder encodes Ogg/Opus in-browser
// (opus-recorder) so there's no server-side transcode.
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const recorderRef = useRef<import("opus-recorder").default | null>(null);
const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Viewers (read-only role) can browse the inbox but never send.
// For solo users this is always true — single-owner accounts pass
// every capability — so the disabled branch is a no-op there.
const canSend = useCan("send-messages");
const readOnly = !canSend;
// Media (like free-form text) is only allowed inside the 24h window.
const inputsDisabled = readOnly || sessionExpired;
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Tear down any live recording + timer on unmount so a mid-record
// navigation doesn't leak the mic, and GC a staged-but-unsent
// attachment so it doesn't orphan in the bucket.
useEffect(() => {
return () => {
clearTimer();
cancelledRef.current = true;
// stop() releases the mic stream + audio context inside opus-recorder.
void recorderRef.current?.stop().catch(() => {});
removeStaged(draftRef.current?.path);
};
}, [clearTimer, removeStaged]);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
// Max 4 lines (~96px)
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
}, []);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || sending || sessionExpired) return;
setSending(true);
try {
onSend(trimmed, replyTo?.id);
setText("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
} finally {
setSending(false);
}
}, [text, sending, sessionExpired, onSend, replyTo?.id]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
adjustHeight();
},
[adjustHeight]
);
// Upload a captured file to chat-media and stage it as a draft.
const stageUpload = useCallback(
async (kind: ComposerMediaKind, file: File) => {
// 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(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round(
max / 1024 / 1024,
)} MB.`,
);
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
// Replacing an existing draft? GC the previous object first.
removeStaged(draftRef.current?.path);
setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const handlePicked = useCallback(
(kind: "image" | "video" | "document", file: File | undefined) => {
if (file) void stageUpload(kind, file);
},
[stageUpload],
);
// ---- Voice recording (client-side Ogg/Opus, no server transcode) ---
// The encoded Ogg/Opus file from opus-recorder → upload as an audio
// draft. WhatsApp renders Ogg/Opus as a playable voice note.
const finalizeRecording = useCallback(
async (bytes: Uint8Array) => {
// Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
type: "audio/ogg",
});
if (file.size === 0) return; // cancelled / empty take
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
toast.error("Recording is too long (over 16 MB).");
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
removeStaged(draftRef.current?.path);
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const startRecording = useCallback(async () => {
if (inputsDisabled || busy || recording) return;
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
toast.error("Voice recording isn't supported in this browser.");
return;
}
try {
// Lazy-load the encoder (≈400 KB worker) only when the user records,
// keeping it out of the main bundle.
const { default: Recorder } = await import("opus-recorder");
const recorder = new Recorder({
encoderPath: OPUS_ENCODER_PATH,
numberOfChannels: 1,
encoderApplication: 2048, // VOIP — tuned for speech
encoderSampleRate: 48000,
streamPages: false, // one callback with the complete file on stop
});
cancelledRef.current = false;
recorder.ondataavailable = (bytes) => {
if (cancelledRef.current) return;
void finalizeRecording(bytes);
};
recorderRef.current = recorder;
await recorder.start();
setRecording(true);
setRecordSeconds(0);
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
} catch {
void recorderRef.current?.stop().catch(() => {});
recorderRef.current = null;
toast.error("Microphone access denied or unavailable.");
}
}, [inputsDisabled, busy, recording, finalizeRecording]);
const stopRecording = useCallback(() => {
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
const cancelRecording = useCallback(() => {
cancelledRef.current = true;
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
// Auto-stop at the cap so a forgotten recording can't blow the
// upload size limit.
useEffect(() => {
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
stopRecording();
}
}, [recording, recordSeconds, stopRecording]);
// ---- Draft send / discard -----------------------------------------
const sendDraft = useCallback(() => {
if (!draft || busy) return;
onSendMedia({
kind: draft.kind,
mediaUrl: draft.mediaUrl,
path: draft.path,
// Audio takes no caption (Meta rejects it). Everything else: the
// trimmed caption, or undefined when blank.
caption:
draft.kind === "audio" ? undefined : draft.caption.trim() || undefined,
filename: draft.kind === "document" ? draft.filename : undefined,
replyToId: replyTo?.id,
});
// The object is now owned by the sent message — clear without GC.
setDraft(null);
onClearReply?.();
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
// 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 --------------------------------------------------------
return (
<div className="border-t border-border bg-card p-3">
{replyTo && (
<div className="mb-2">
<ReplyQuote
authorLabel={replyTo.authorLabel}
preview={replyTo.preview}
onDismiss={onClearReply}
/>
</div>
)}
{sessionExpired && (
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
<p className="text-xs text-amber-400">
24-hour session expired. Use a template to re-engage.
</p>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-amber-400 hover:text-amber-300"
onClick={onOpenTemplates}
>
<LayoutTemplate className="mr-1 h-3 w-3" />
Templates
</Button>
</div>
)}
{/* Hidden file inputs driven by the attach menu. */}
<input
ref={imageInputRef}
type="file"
accept={PICKER_ACCEPT.image}
className="hidden"
onChange={(e) => {
handlePicked("image", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={videoInputRef}
type="file"
accept={PICKER_ACCEPT.video}
className="hidden"
onChange={(e) => {
handlePicked("video", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={documentInputRef}
type="file"
accept={PICKER_ACCEPT.document}
className="hidden"
onChange={(e) => {
handlePicked("document", e.target.files?.[0]);
e.target.value = "";
}}
/>
{draft ? (
<MediaDraftPreview
draft={draft}
busy={busy}
readOnly={readOnly}
onCaptionChange={setCaption}
onDiscard={discardDraft}
onSend={sendDraft}
/>
) : recording ? (
// 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">
<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">
Recording… {formatDuration(recordSeconds)} /{" "}
{formatDuration(MAX_RECORDING_SECONDS)}
</span>
<button
type="button"
onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
>
Cancel
</button>
<Button
size="sm"
onClick={stopRecording}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
title="Stop and attach"
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-end gap-2">
{/* Attach menu — photo / video / document / voice. */}
<DropdownMenu>
<DropdownMenuTrigger
disabled={inputsDisabled || busy}
title={
readOnly
? "Read-only — your role can't send messages"
: inputsDisabled
? undefined
: "Attach media"
}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" />
Photo
</DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" />
Video
</DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Document
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" />
Voice note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
title={readOnly ? undefined : "Send template"}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates}
>
<LayoutTemplate className="h-4 w-4" />
</GatedButton>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={
readOnly
? "Read-only — viewers can browse but not reply"
: sessionExpired
? "Session expired - use a template"
: "Type a message... (Shift+Enter for new line)"
}
disabled={sessionExpired || readOnly}
rows={1}
// Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs.
// The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn(
"flex-1 resize-none 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",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)}
/>
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={!text.trim() || sessionExpired || sending}
onClick={handleSend}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
)}
{/* Hint sits outside the flex row so its height doesn't push
`items-end` buttons below the textarea. Indented to line up
under the textarea left edge. */}
{!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Type &apos;/&apos; for quick replies
</p>
)}
</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>
);
}