Files
Messenger/web/src/components/MessageComposer.tsx

943 lines
36 KiB
TypeScript

import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { buildWsUrl } from "../utils/ws";
import { getAppPreferences } from "../utils/preferences";
type RecordingState = "idle" | "recording" | "locked";
export function MessageComposer() {
const activeChatId = useChatStore((s) => s.activeChatId);
const chats = useChatStore((s) => s.chats);
const me = useAuthStore((s) => s.me);
const draftsByChat = useChatStore((s) => s.draftsByChat);
const setDraft = useChatStore((s) => s.setDraft);
const clearDraft = useChatStore((s) => s.clearDraft);
const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage);
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
const replyToByChat = useChatStore((s) => s.replyToByChat);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const recordingStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const sendVoiceOnStopRef = useRef<boolean>(true);
const recordingStartedAtRef = useRef<number | null>(null);
const pointerStartYRef = useRef<number>(0);
const pointerStartXRef = useRef<number>(0);
const pointerCancelArmedRef = useRef<boolean>(false);
const pointerMoveHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
const pointerUpHandlerRef = useRef<((event: globalThis.PointerEvent) => void) | null>(null);
const recordingStateRef = useRef<RecordingState>("idle");
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
const [sendAsCircle, setSendAsCircle] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [showFormatMenu, setShowFormatMenu] = useState(false);
const [captionDraft, setCaptionDraft] = useState("");
const mediaInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [recordingState, setRecordingState] = useState<RecordingState>("idle");
const [recordSeconds, setRecordSeconds] = useState(0);
const [dragHint, setDragHint] = useState<"idle" | "lock" | "cancel">("idle");
const hasTextToSend = text.trim().length > 0;
const activeChat = chats.find((chat) => chat.id === activeChatId);
const canSendInActiveChat = Boolean(
activeChatId &&
activeChat &&
(activeChat.type !== "channel" || activeChat.my_role === "owner" || activeChat.my_role === "admin" || activeChat.is_saved)
);
useEffect(() => {
recordingStateRef.current = recordingState;
}, [recordingState]);
useEffect(() => {
if (!activeChatId) {
if (text !== "") {
setText("");
}
return;
}
const draft = draftsByChat[activeChatId] ?? "";
if (draft !== text) {
setText(draft);
}
}, [activeChatId, draftsByChat, text]);
useEffect(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
if (recordingStateRef.current !== "idle") {
stopRecord(false);
}
if (pointerMoveHandlerRef.current) {
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
}
if (pointerUpHandlerRef.current) {
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
}
};
}, [previewUrl]);
useEffect(() => {
if (!activeChatId && recordingStateRef.current !== "idle") {
stopRecord(false);
}
}, [activeChatId]);
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "hidden" && recordingStateRef.current === "recording") {
setRecordingState("locked");
}
};
const onPageHide = () => {
if (recordingStateRef.current !== "idle") {
stopRecord(false);
}
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pagehide", onPageHide);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pagehide", onPageHide);
};
}, []);
useEffect(() => {
if (recordingState === "idle") {
return;
}
const interval = window.setInterval(() => {
if (!recordingStartedAtRef.current) {
return;
}
const sec = Math.max(0, Math.floor((Date.now() - recordingStartedAtRef.current) / 1000));
setRecordSeconds(sec);
}, 250);
return () => window.clearInterval(interval);
}, [recordingState]);
function makeClientMessageId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function getWs(): WebSocket | null {
if (!accessToken || !activeChatId) {
return null;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return wsRef.current;
}
const wsUrl = buildWsUrl(accessToken);
wsRef.current = new WebSocket(wsUrl);
return wsRef.current;
}
async function handleSend() {
if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) {
return;
}
const clientMessageId = makeClientMessageId();
const textValue = text.trim();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
try {
let message = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId);
break;
} catch (error) {
if (attempt === 1) {
throw error;
}
await new Promise((resolve) => window.setTimeout(resolve, 250));
}
}
if (!message) {
throw new Error("send failed");
}
confirmMessageByClientId(activeChatId, clientMessageId, message);
setText("");
clearDraft(activeChatId);
setReplyToMessage(activeChatId, null);
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Message send failed. Please try again.");
}
}
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter") {
return;
}
const prefs = getAppPreferences();
const sendWithCtrlEnter = prefs.sendMode === "ctrl_enter";
if (sendWithCtrlEnter) {
if (event.ctrlKey) {
event.preventDefault();
void handleSend();
}
return;
}
if (!event.shiftKey) {
event.preventDefault();
void handleSend();
}
}
async function handleUpload(
file: File,
messageType: "file" | "image" | "video" | "audio" | "voice" | "circle_video" = "file",
waveformPoints?: number[] | null
) {
if (!activeChatId || !me || !canSendInActiveChat) {
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
const clientMessageId = makeClientMessageId();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
addOptimisticMessage({
chatId: activeChatId,
senderId: me.id,
type: messageType,
text: upload.file_url,
clientMessageId,
});
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
const messageWithWaveform = waveformPoints?.length ? { ...message, attachment_waveform: waveformPoints } : message;
confirmMessageByClientId(activeChatId, clientMessageId, messageWithWaveform);
try {
await attachFile(
message.id,
upload.file_url,
file.type || "application/octet-stream",
file.size,
waveformPoints ?? null
);
} catch {
setUploadError("File sent, but metadata save failed. Please refresh chat.");
}
setReplyToMessage(activeChatId, null);
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Upload failed. Please try again.");
} finally {
setIsUploading(false);
}
}
function inferType(file: File): "file" | "image" | "video" | "audio" {
if (file.type.startsWith("image/")) return "image";
if (file.type.startsWith("video/")) return "video";
if (file.type.startsWith("audio/")) return "audio";
return "file";
}
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(file);
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
image.src = url;
});
}
async function compressImageForWeb(file: File): Promise<File> {
const image = await loadImageFromFile(file);
const maxSide = 1920;
const ratio = Math.min(1, maxSide / Math.max(image.width, image.height));
const width = Math.max(1, Math.round(image.width * ratio));
const height = Math.max(1, Math.round(image.height * ratio));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
return file;
}
ctx.drawImage(image, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((result) => resolve(result), "image/jpeg", 0.82);
});
if (!blob || blob.size >= file.size) {
return file;
}
const baseName = file.name.replace(/\.[^/.]+$/, "");
return new File([blob], `${baseName || "image"}-web.jpg`, { type: "image/jpeg" });
}
async function prepareFileForUpload(file: File, fileType: "file" | "image" | "video" | "audio"): Promise<File> {
if (fileType === "image") {
return compressImageForWeb(file);
}
return file;
}
async function startRecord() {
if (recordingState !== "idle" || !canSendInActiveChat) {
return false;
}
try {
if (!("mediaDevices" in navigator) || !navigator.mediaDevices.getUserMedia) {
setUploadError("Microphone is not supported in this browser.");
return false;
}
if (navigator.permissions && navigator.permissions.query) {
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
if (permission.state === "denied") {
setUploadError("Microphone access denied. Allow microphone in browser site permissions.");
return false;
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
recordingStreamRef.current = stream;
chunksRef.current = [];
sendVoiceOnStopRef.current = true;
recorder.ondataavailable = (event) => chunksRef.current.push(event.data);
recorder.onstop = async () => {
const shouldSend = sendVoiceOnStopRef.current;
const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : 0;
const data = [...chunksRef.current];
chunksRef.current = [];
if (recordingStreamRef.current) {
recordingStreamRef.current.getTracks().forEach((track) => track.stop());
recordingStreamRef.current = null;
}
recordingStartedAtRef.current = null;
if (!shouldSend || data.length === 0) {
return;
}
if (durationMs < 1000) {
setUploadError("Voice message is too short. Minimum length is 1 second.");
return;
}
const blob = new Blob(data, { type: "audio/webm" });
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
const waveform = await buildWaveformPoints(blob, 64);
await handleUpload(file, "voice", waveform);
};
recorderRef.current = recorder;
recorder.start();
recordingStartedAtRef.current = Date.now();
setRecordSeconds(0);
setRecordingState("recording");
return true;
} catch {
setUploadError("Microphone access denied. Please allow microphone and retry.");
return false;
}
}
function stopRecord(send: boolean) {
sendVoiceOnStopRef.current = send;
pointerCancelArmedRef.current = false;
setDragHint("idle");
if (recorderRef.current && recorderRef.current.state !== "inactive") {
recorderRef.current.stop();
}
recorderRef.current = null;
setRecordingState("idle");
setRecordSeconds(0);
if (pointerMoveHandlerRef.current) {
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
pointerMoveHandlerRef.current = null;
}
if (pointerUpHandlerRef.current) {
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
pointerUpHandlerRef.current = null;
}
}
async function onMicPointerDown(event: PointerEvent<HTMLButtonElement>) {
event.preventDefault();
const started = await startRecord();
if (!started) {
return;
}
pointerStartYRef.current = event.clientY;
pointerStartXRef.current = event.clientX;
pointerCancelArmedRef.current = false;
setDragHint("idle");
if (pointerMoveHandlerRef.current) {
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
}
if (pointerUpHandlerRef.current) {
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
}
const onPointerMove = (moveEvent: globalThis.PointerEvent) => {
const current = recordingStateRef.current;
if (current === "idle") {
return;
}
const deltaY = pointerStartYRef.current - moveEvent.clientY;
const deltaX = pointerStartXRef.current - moveEvent.clientX;
if (current === "recording" && deltaY > 70) {
setRecordingState("locked");
setDragHint("idle");
return;
}
if (current === "recording") {
if (deltaX > 90) {
pointerCancelArmedRef.current = true;
setDragHint("cancel");
} else if (deltaY > 40) {
pointerCancelArmedRef.current = false;
setDragHint("lock");
} else {
pointerCancelArmedRef.current = false;
setDragHint("idle");
}
}
};
const finishPointerSession = () => {
if (pointerMoveHandlerRef.current) {
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
}
if (pointerUpHandlerRef.current) {
window.removeEventListener("pointerup", pointerUpHandlerRef.current);
}
pointerMoveHandlerRef.current = null;
pointerUpHandlerRef.current = null;
const current = recordingStateRef.current;
if (current === "recording") {
stopRecord(!pointerCancelArmedRef.current);
}
setDragHint("idle");
};
const onPointerUp = () => {
finishPointerSession();
};
const onPointerCancel = () => {
pointerCancelArmedRef.current = true;
finishPointerSession();
};
pointerMoveHandlerRef.current = onPointerMove;
pointerUpHandlerRef.current = onPointerUp;
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel, { once: true });
}
function selectFiles(files: File[]) {
if (!files.length) {
return;
}
setUploadError(null);
setSelectedFiles(files);
setShowAttachMenu(false);
const fileType = inferType(files[0]);
setSelectedType(fileType);
setSendAsCircle(false);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
if (files.length === 1 && (fileType === "image" || fileType === "video")) {
setPreviewUrl(URL.createObjectURL(files[0]));
} else {
setPreviewUrl(null);
}
}
async function sendSelectedFiles() {
if (!selectedFiles.length || !activeChatId || !me) {
return;
}
setIsUploading(true);
setUploadError(null);
setUploadProgress(0);
const prepared = await Promise.all(
selectedFiles.map(async (file) => {
const kind = inferType(file);
const uploadFile = await prepareFileForUpload(file, kind);
return { file: uploadFile, kind };
})
);
const uploaded: Array<{ fileUrl: string; fileType: string; fileSize: number; kind: "file" | "image" | "video" | "audio" }> = [];
try {
for (let index = 0; index < prepared.length; index += 1) {
const current = prepared[index];
const upload = await requestUploadUrl(current.file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, current.file, (percent) => {
const done = index / prepared.length;
const currentPart = (percent / 100) / prepared.length;
setUploadProgress(Math.min(100, Math.round((done + currentPart) * 100)));
});
uploaded.push({
fileUrl: upload.file_url,
fileType: current.file.type || "application/octet-stream",
fileSize: current.file.size,
kind: current.kind,
});
}
const kindSet = new Set(uploaded.map((item) => item.kind));
let inferredType: "file" | "image" | "video" | "audio" = "file";
if ([...kindSet].every((kind) => kind === "image")) {
inferredType = "image";
} else if ([...kindSet].every((kind) => kind === "image" || kind === "video")) {
inferredType = "video";
} else if ([...kindSet].every((kind) => kind === "audio")) {
inferredType = "audio";
} else if (kindSet.size === 1) {
inferredType = uploaded[0].kind;
}
const messageType =
inferredType === "video" && uploaded.length === 1 && sendAsCircle ? "circle_video" : inferredType;
const caption = captionDraft.trim() || null;
const clientMessageId = makeClientMessageId();
addOptimisticMessage({
chatId: activeChatId,
senderId: me.id,
type: messageType,
text: caption,
clientMessageId,
});
const replyToMessageId = replyToByChat[activeChatId]?.id ?? undefined;
const created = await sendMessageWithClientId(
activeChatId,
caption,
messageType,
clientMessageId,
replyToMessageId
);
confirmMessageByClientId(activeChatId, clientMessageId, created);
for (const item of uploaded) {
await attachFile(created.id, item.fileUrl, item.fileType, item.fileSize);
}
setReplyToMessage(activeChatId, null);
} catch {
setUploadError("Upload failed. Please try again.");
} finally {
setIsUploading(false);
setUploadProgress(0);
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFiles([]);
setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file");
setSendAsCircle(false);
}
function cancelSelectedFile() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFiles([]);
setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file");
setSendAsCircle(false);
setUploadProgress(0);
setUploadError(null);
}
function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function insertFormatting(startTag: string, endTag = startTag, placeholder = "text") {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const start = textarea.selectionStart ?? text.length;
const end = textarea.selectionEnd ?? text.length;
const selected = text.slice(start, end);
const middle = selected || placeholder;
const nextValue = `${text.slice(0, start)}${startTag}${middle}${endTag}${text.slice(end)}`;
setText(nextValue);
if (activeChatId) {
setDraft(activeChatId, nextValue);
}
requestAnimationFrame(() => {
textarea.focus();
if (selected) {
const pos = start + startTag.length + middle.length + endTag.length;
textarea.setSelectionRange(pos, pos);
} else {
const selStart = start + startTag.length;
const selEnd = selStart + middle.length;
textarea.setSelectionRange(selStart, selEnd);
}
});
}
function insertLink() {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const start = textarea.selectionStart ?? text.length;
const end = textarea.selectionEnd ?? text.length;
const selected = text.slice(start, end).trim() || "text";
const href = window.prompt("Enter URL (https://...)");
if (!href) {
return;
}
const link = `[${selected}](${href.trim()})`;
const nextValue = `${text.slice(0, start)}${link}${text.slice(end)}`;
setText(nextValue);
if (activeChatId) {
setDraft(activeChatId, nextValue);
}
requestAnimationFrame(() => {
textarea.focus();
const pos = start + link.length;
textarea.setSelectionRange(pos, pos);
});
}
return (
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
{activeChatId && replyToByChat[activeChatId] ? (
<div className="mb-2 flex items-start justify-between rounded-lg border border-slate-700/80 bg-slate-800/70 px-3 py-2 text-xs">
<div className="min-w-0">
<p className="font-semibold text-sky-300">Replying</p>
<p className="truncate text-slate-300">{replyToByChat[activeChatId]?.text || "[media]"}</p>
</div>
<button className="ml-2 rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setReplyToMessage(activeChatId, null)}>
Cancel
</button>
</div>
) : null}
{recordingState !== "idle" ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-200">
<div className="flex items-center justify-between">
<span>🎤 Recording {formatDuration(recordSeconds)}</span>
{recordingState === "recording" ? (
<span className={dragHint === "cancel" ? "text-red-300" : dragHint === "lock" ? "text-sky-300" : "text-slate-400"}>
{dragHint === "cancel" ? "Release to cancel" : dragHint === "lock" ? "Release up to lock" : "Slide up to lock, left to cancel"}
</span>
) : (
<span className="text-sky-300">Locked</span>
)}
</div>
{recordingState === "locked" ? (
<div className="mt-2 flex gap-2">
<button className="w-full rounded bg-slate-700 px-3 py-1.5" onClick={() => stopRecord(false)} type="button">
Cancel
</button>
<button className="w-full rounded bg-sky-500 px-3 py-1.5 font-semibold text-slate-950" onClick={() => stopRecord(true)} type="button">
Send Voice
</button>
</div>
) : null}
</div>
) : null}
{showFormatMenu ? (
<div className="mb-2 flex items-center gap-1 rounded-2xl border border-slate-700/80 bg-slate-900/95 px-2 py-1.5">
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("||", "||")} type="button" title="Spoiler">
👁
</button>
<button className="rounded px-2 py-1 text-xs font-semibold hover:bg-slate-800" onClick={() => insertFormatting("**", "**")} type="button" title="Bold">
B
</button>
<button className="rounded px-2 py-1 text-xs italic hover:bg-slate-800" onClick={() => insertFormatting("*", "*")} type="button" title="Italic">
I
</button>
<button className="rounded px-2 py-1 text-xs underline hover:bg-slate-800" onClick={() => insertFormatting("__", "__")} type="button" title="Underline">
U
</button>
<button className="rounded px-2 py-1 text-xs line-through hover:bg-slate-800" onClick={() => insertFormatting("~~", "~~")} type="button" title="Strikethrough">
S
</button>
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={() => insertFormatting("`", "`")} type="button" title="Monospace">
M
</button>
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={insertLink} type="button" title="Link">
🔗
</button>
</div>
) : null}
{!canSendInActiveChat && activeChat?.type === "channel" ? (
<div className="mb-2 rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
Read-only channel: only owners and admins can post.
</div>
) : null}
<div className="mb-2 flex items-end gap-2">
<div className="relative">
<button
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-3 text-sm font-semibold text-slate-200 hover:bg-slate-700 disabled:opacity-60"
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
onClick={() => setShowAttachMenu((v) => !v)}
type="button"
>
📎
</button>
{showAttachMenu ? (
<div className="absolute bottom-12 left-0 z-20 w-44 rounded-xl border border-slate-700/80 bg-slate-900/95 p-1.5 shadow-2xl">
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => mediaInputRef.current?.click()}
type="button"
>
Photo or Video
</button>
<button
className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800"
onClick={() => fileInputRef.current?.click()}
type="button"
>
File
</button>
</div>
) : null}
<input
ref={mediaInputRef}
className="hidden"
type="file"
multiple
accept="image/*,video/*"
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
onChange={(event) => {
const files = event.target.files ? Array.from(event.target.files) : [];
if (files.length) {
selectFiles(files);
}
event.currentTarget.value = "";
}}
/>
<input
ref={fileInputRef}
className="hidden"
type="file"
multiple
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
onChange={(event) => {
const files = event.target.files ? Array.from(event.target.files) : [];
if (files.length) {
selectFiles(files);
}
event.currentTarget.value = "";
}}
/>
</div>
<button
className="h-[42px] min-w-[42px] rounded-full bg-slate-700/85 px-2 text-xs font-semibold text-slate-200 hover:bg-slate-700"
onClick={() => setShowFormatMenu((v) => !v)}
type="button"
title="Text formatting"
disabled={!canSendInActiveChat}
>
Aa
</button>
<textarea
ref={textareaRef}
className="min-h-[42px] max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
placeholder={canSendInActiveChat ? "Write a message..." : "Read-only channel"}
rows={1}
value={text}
disabled={!canSendInActiveChat}
onKeyDown={onComposerKeyDown}
onChange={(event) => {
const next = event.target.value;
setText(next);
if (activeChatId) {
setDraft(activeChatId, next);
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
}
}}
/>
{hasTextToSend ? (
<button
className="h-[42px] w-[56px] rounded-full bg-sky-500 px-3 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:opacity-60"
disabled={recordingState !== "idle" || !activeChatId || !canSendInActiveChat}
onClick={handleSend}
type="button"
title="Send message"
>
</button>
) : (
<button
className={`h-[42px] w-[56px] rounded-full px-3 text-sm font-semibold ${
recordingState === "idle" ? "bg-emerald-500 text-slate-950 hover:bg-emerald-400" : "bg-slate-700 text-slate-300"
}`}
disabled={isUploading || !activeChatId || !canSendInActiveChat}
onPointerDown={onMicPointerDown}
type="button"
title="Hold to record voice"
>
🎤
</button>
)}
</div>
{selectedFiles.length > 0 ? (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/80 p-3" onClick={cancelSelectedFile}>
<div className="flex h-full w-full max-w-2xl flex-col rounded-2xl border border-slate-700/80 bg-slate-900/95 shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-700/70 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">Send {selectedFiles.length > 1 ? "Attachments" : (selectedType === "image" || selectedType === "video" ? "Photo" : "File")}</p>
<p className="truncate text-xs text-slate-400">
{selectedFiles.length === 1
? `${selectedFiles[0].name}${formatBytes(selectedFiles[0].size)}`
: `${selectedFiles.length} files • ${formatBytes(selectedFiles.reduce((acc, file) => acc + file.size, 0))}`}
</p>
</div>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
Close
</button>
</div>
<div className="tg-scrollbar min-h-0 flex-1 overflow-auto p-3">
{previewUrl && selectedType === "image" && selectedFiles.length === 1 ? (
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFiles[0].name} />
) : null}
{previewUrl && selectedType === "video" && selectedFiles.length === 1 ? (
<video className="mx-auto max-h-[70vh] w-full rounded-xl" src={previewUrl} controls muted />
) : null}
{!previewUrl ? (
<div className="rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
{selectedFiles.slice(0, 8).map((file) => (
<p className="truncate" key={`${file.name}-${file.size}`}>{file.name}</p>
))}
{selectedFiles.length > 8 ? <p className="mt-1 text-slate-400">+{selectedFiles.length - 8} more</p> : null}
</div>
) : null}
</div>
<div className="border-t border-slate-700/70 p-3">
<input
className="mb-2 w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
maxLength={1000}
placeholder="Add a caption..."
value={captionDraft}
onChange={(event) => setCaptionDraft(event.target.value)}
/>
{selectedType === "video" && selectedFiles.length === 1 ? (
<label className="mb-2 flex items-center gap-2 text-xs text-slate-300">
<input
checked={sendAsCircle}
onChange={(event) => setSendAsCircle(event.target.checked)}
type="checkbox"
/>
Send as video message (circle)
</label>
) : null}
{isUploading ? (
<div className="mb-2">
<div className="mb-1 text-xs text-slate-300">Uploading: {uploadProgress}%</div>
<div className="h-2 rounded bg-slate-700">
<div className="h-2 rounded bg-sky-500 transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
) : null}
<div className="flex gap-2">
<button
className="w-full rounded-xl bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50"
onClick={() => void sendSelectedFiles()}
disabled={isUploading}
>
Send
</button>
<button className="w-full rounded-xl bg-slate-700 px-3 py-2 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
Cancel
</button>
</div>
</div>
</div>
</div>
) : null}
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
</div>
);
}
async function buildWaveformPoints(blob: Blob, bars = 64): Promise<number[] | null> {
try {
const buffer = await blob.arrayBuffer();
const audioContext = new AudioContext();
try {
const audioBuffer = await audioContext.decodeAudioData(buffer.slice(0));
const channel = audioBuffer.getChannelData(0);
if (!channel.length || bars < 8) {
return null;
}
const blockSize = Math.max(1, Math.floor(channel.length / bars));
const points: number[] = [];
for (let i = 0; i < bars; i += 1) {
const start = i * blockSize;
const end = Math.min(channel.length, start + blockSize);
let sum = 0;
for (let j = start; j < end; j += 1) {
const sample = channel[j];
sum += sample * sample;
}
const rms = Math.sqrt(sum / Math.max(1, end - start));
points.push(Math.max(1, Math.min(31, Math.round(rms * 42))));
}
return points;
} finally {
await audioContext.close();
}
} catch {
return null;
}
}
function formatDuration(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}