Files
Messenger/web/src/components/MessageComposer.tsx
Codex ad2e0ede42
Some checks failed
CI / test (push) Failing after 2m20s
web: fix auth session races, ws token drift, and unread clear behavior
2026-03-09 02:17:14 +03:00

1570 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { attachFile, editMessage, 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";
const STICKER_PRESETS: Array<{ name: string; url: string }> = [
{ name: "Thumbs Up", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44d.png" },
{ name: "Party", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f389.png" },
{ name: "Fire", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f525.png" },
{ name: "Heart", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2764.png" },
{ name: "Cool", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f60e.png" },
{ name: "Rocket", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f680.png" },
{ name: "Clap", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44f.png" },
{ name: "Star", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2b50.png" },
];
const GIF_PRESETS: Array<{ name: string; url: string }> = [
{ name: "Cat Typing", url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" },
{ name: "Thumbs Up", url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif" },
{ name: "Excited", url: "https://media.giphy.com/media/5VKbvrjxpVJCM/giphy.gif" },
{ name: "Dance", url: "https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif" },
{ name: "Nice", url: "https://media.giphy.com/media/3o7abKhOpu0NwenH3O/giphy.gif" },
{ name: "Wow", url: "https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif" },
{ name: "Thanks", url: "https://media.giphy.com/media/26ufdipQqU2lhNA4g/giphy.gif" },
{ name: "Success", url: "https://media.giphy.com/media/4T7e4DmcrP9du/giphy.gif" },
];
const STICKER_FAVORITES_KEY = "bm_sticker_favorites_v1";
const GIF_FAVORITES_KEY = "bm_gif_favorites_v1";
const GIF_PROVIDER = (import.meta.env.VITE_GIF_PROVIDER ?? "").toLowerCase();
const TENOR_API_KEY = (import.meta.env.VITE_TENOR_API_KEY ?? "").trim();
const TENOR_CLIENT_KEY = (import.meta.env.VITE_TENOR_CLIENT_KEY ?? "benya_messenger_web").trim();
const GIPHY_API_KEY = (import.meta.env.VITE_GIPHY_API_KEY ?? "").trim();
const GIF_SEARCH_ENABLED =
(GIF_PROVIDER === "tenor" && TENOR_API_KEY.length > 0) ||
(GIF_PROVIDER === "giphy" && GIPHY_API_KEY.length > 0);
function loadFavorites(key: string): Set<string> {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return new Set();
const parsed = JSON.parse(raw) as string[];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item) => typeof item === "string"));
} catch {
return new Set();
}
}
function saveFavorites(key: string, values: Set<string>): void {
try {
window.localStorage.setItem(key, JSON.stringify([...values]));
} catch {
return;
}
}
function pickSupportedAudioMimeType(): string | undefined {
if (typeof MediaRecorder === "undefined" || typeof MediaRecorder.isTypeSupported !== "function") {
return undefined;
}
const candidates = [
"audio/ogg;codecs=opus",
"audio/mp4",
"audio/webm;codecs=opus",
"audio/webm",
];
for (const mime of candidates) {
if (MediaRecorder.isTypeSupported(mime)) {
return mime;
}
}
return undefined;
}
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 editingByChat = useChatStore((s) => s.editingByChat);
const setEditingMessage = useChatStore((s) => s.setEditingMessage);
const upsertMessage = useChatStore((s) => s.upsertMessage);
const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null);
const wsTokenRef = useRef<string | 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 lastEditingMessageIdRef = useRef<number | null>(null);
const prevActiveChatIdRef = useRef<number | null>(null);
const typingActiveRef = useRef(false);
const typingStopTimerRef = useRef<number | null>(null);
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 [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [showFormatMenu, setShowFormatMenu] = useState(false);
const [showStickerMenu, setShowStickerMenu] = useState(false);
const [showGifMenu, setShowGifMenu] = useState(false);
const [stickerTab, setStickerTab] = useState<"all" | "favorites">("all");
const [stickerQuery, setStickerQuery] = useState("");
const [gifTab, setGifTab] = useState<"all" | "favorites">("all");
const [gifQuery, setGifQuery] = useState("");
const [gifResults, setGifResults] = useState<Array<{ name: string; url: string }>>([]);
const [gifLoading, setGifLoading] = useState(false);
const [gifSearchError, setGifSearchError] = useState<string | null>(null);
const [favoriteStickers, setFavoriteStickers] = useState<Set<string>>(() => loadFavorites(STICKER_FAVORITES_KEY));
const [favoriteGifs, setFavoriteGifs] = useState<Set<string>>(() => loadFavorites(GIF_FAVORITES_KEY));
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 editingMessage = activeChatId ? (editingByChat[activeChatId] ?? null) : null;
const canSendInActiveChat = Boolean(
activeChatId &&
activeChat &&
(activeChat.type !== "channel" || activeChat.my_role === "owner" || activeChat.my_role === "admin" || activeChat.is_saved)
);
async function searchGifs(term: string): Promise<Array<{ name: string; url: string }>> {
if (GIF_PROVIDER === "tenor") {
const params = new URLSearchParams({
q: term,
key: TENOR_API_KEY,
client_key: TENOR_CLIENT_KEY,
limit: "24",
media_filter: "gif",
contentfilter: "medium",
});
const response = await fetch(`https://tenor.googleapis.com/v2/search?${params.toString()}`);
if (!response.ok) {
throw new Error("tenor search failed");
}
const data = (await response.json()) as {
results?: Array<{ content_description?: string; media_formats?: { gif?: { url?: string } } }>;
};
return (
data.results
?.map((item) => ({
name: item.content_description || "GIF",
url: item.media_formats?.gif?.url || "",
}))
.filter((item) => item.url.length > 0) ?? []
);
}
if (GIF_PROVIDER === "giphy") {
const params = new URLSearchParams({
api_key: GIPHY_API_KEY,
q: term,
limit: "24",
rating: "pg-13",
lang: "en",
});
const response = await fetch(`https://api.giphy.com/v1/gifs/search?${params.toString()}`);
if (!response.ok) {
throw new Error("giphy search failed");
}
const data = (await response.json()) as {
data?: Array<{
title?: string;
images?: {
fixed_height?: { url?: string };
original?: { url?: string };
};
}>;
};
return (
data.data
?.map((item) => ({
name: item.title || "GIF",
url: item.images?.fixed_height?.url || item.images?.original?.url || "",
}))
.filter((item) => item.url.length > 0) ?? []
);
}
return [];
}
useEffect(() => {
recordingStateRef.current = recordingState;
}, [recordingState]);
useEffect(() => {
if (!activeChatId) {
if (text !== "") {
setText("");
}
lastEditingMessageIdRef.current = null;
return;
}
if (editingMessage) {
if (lastEditingMessageIdRef.current !== editingMessage.id) {
setText(editingMessage.text ?? "");
}
lastEditingMessageIdRef.current = editingMessage.id;
return;
}
lastEditingMessageIdRef.current = null;
const draft = draftsByChat[activeChatId] ?? "";
if (draft !== text) {
setText(draft);
}
}, [activeChatId, draftsByChat, editingMessage, 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);
}
if (typingStopTimerRef.current !== null) {
window.clearTimeout(typingStopTimerRef.current);
}
wsRef.current?.close();
wsRef.current = null;
wsTokenRef.current = null;
};
}, [previewUrl]);
useEffect(() => {
const activeToken = accessToken ?? null;
if (wsRef.current && wsTokenRef.current !== activeToken) {
wsRef.current.close();
wsRef.current = null;
}
wsTokenRef.current = activeToken;
}, [accessToken]);
useEffect(() => {
if (!activeChatId && recordingStateRef.current !== "idle") {
stopRecord(false);
}
}, [activeChatId]);
useEffect(() => {
const prev = prevActiveChatIdRef.current;
if (prev && prev !== activeChatId) {
sendWsEvent("typing_stop", { chat_id: prev });
typingActiveRef.current = false;
if (typingStopTimerRef.current !== null) {
window.clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
}
prevActiveChatIdRef.current = activeChatId;
}, [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 && wsTokenRef.current !== accessToken) {
wsRef.current.close();
wsRef.current = null;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return wsRef.current;
}
const wsUrl = buildWsUrl(accessToken);
wsRef.current = new WebSocket(wsUrl);
wsTokenRef.current = accessToken;
return wsRef.current;
}
function sendWsEvent(eventName: string, payload: Record<string, unknown>) {
const ws = getWs();
if (!ws || ws.readyState !== WebSocket.OPEN) {
return;
}
try {
ws.send(JSON.stringify({ event: eventName, payload }));
} catch {
return;
}
}
function sendRealtimeChatEvent(
eventName:
| "typing_start"
| "typing_stop"
| "recording_voice_start"
| "recording_voice_stop"
) {
if (!activeChatId) {
return;
}
sendWsEvent(eventName, { chat_id: activeChatId });
}
function emitTypingStopIfActive() {
if (!activeChatId) {
return;
}
if (typingStopTimerRef.current !== null) {
window.clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
if (typingActiveRef.current) {
sendRealtimeChatEvent("typing_stop");
typingActiveRef.current = false;
}
}
function syncTypingForText(nextText: string) {
if (!activeChatId) {
return;
}
const hasText = nextText.trim().length > 0;
if (!hasText) {
emitTypingStopIfActive();
return;
}
if (!typingActiveRef.current) {
sendRealtimeChatEvent("typing_start");
typingActiveRef.current = true;
}
if (typingStopTimerRef.current !== null) {
window.clearTimeout(typingStopTimerRef.current);
}
typingStopTimerRef.current = window.setTimeout(() => {
if (!typingActiveRef.current) {
return;
}
sendRealtimeChatEvent("typing_stop");
typingActiveRef.current = false;
typingStopTimerRef.current = null;
}, 2500);
}
async function handleSend() {
if (!activeChatId || !text.trim() || !me || !canSendInActiveChat) {
return;
}
if (editingMessage) {
try {
const updated = await editMessage(editingMessage.id, text.trim());
upsertMessage(activeChatId, updated);
setEditingMessage(activeChatId, null);
setText("");
clearDraft(activeChatId);
} catch {
setUploadError("Edit failed. Message may be older than 7 days.");
}
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);
emitTypingStopIfActive();
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Message send failed. Please try again.");
}
}
async function sendPresetMedia(url: string) {
if (!activeChatId || !me || !canSendInActiveChat) {
return;
}
const clientMessageId = makeClientMessageId();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "image", text: url, clientMessageId });
try {
const message = await sendMessageWithClientId(activeChatId, url, "image", clientMessageId, replyToMessageId);
confirmMessageByClientId(activeChatId, clientMessageId, message);
setReplyToMessage(activeChatId, null);
setShowStickerMenu(false);
setShowGifMenu(false);
setGifQuery("");
} catch {
removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Failed to send media");
}
}
function toggleStickerFavorite(url: string) {
setFavoriteStickers((prev) => {
const next = new Set(prev);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
saveFavorites(STICKER_FAVORITES_KEY, next);
return next;
});
}
function toggleGifFavorite(url: string) {
setFavoriteGifs((prev) => {
const next = new Set(prev);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
saveFavorites(GIF_FAVORITES_KEY, next);
return next;
});
}
useEffect(() => {
const term = gifQuery.trim();
if (!showGifMenu || term.length < 2) {
setGifResults([]);
setGifLoading(false);
setGifSearchError(null);
return;
}
if (!GIF_SEARCH_ENABLED) {
setGifResults([]);
setGifLoading(false);
setGifSearchError("GIF search is not configured. Using preset GIFs.");
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setGifLoading(true);
setGifSearchError(null);
void (async () => {
try {
const rows = await searchGifs(term);
if (cancelled) return;
setGifResults(rows);
} catch {
if (!cancelled) {
setGifResults([]);
setGifSearchError("GIF search is unavailable right now. Using preset GIFs.");
}
} finally {
if (!cancelled) {
setGifLoading(false);
}
}
})();
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [gifQuery, showGifMenu]);
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
const hasModifier = event.ctrlKey || event.metaKey;
if (hasModifier) {
const key = event.key.toLowerCase();
if (key === "b") {
event.preventDefault();
insertFormatting("**", "**");
return;
}
if (key === "i") {
event.preventDefault();
insertFormatting("*", "*");
return;
}
if (key === "u") {
event.preventDefault();
insertFormatting("__", "__");
return;
}
if (key === "k") {
event.preventDefault();
insertLink();
return;
}
if (event.shiftKey && key === "x") {
event.preventDefault();
insertFormatting("~~", "~~");
return;
}
if (event.shiftKey && (event.key === "`" || event.code === "Backquote")) {
event.preventDefault();
insertFormatting("`", "`");
return;
}
}
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" = "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 mimeType = pickSupportedAudioMimeType();
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : 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 outputMime = recorder.mimeType || mimeType || "audio/webm";
const blob = new Blob(data, { type: outputMime });
if (blob.size < 512) {
setUploadError("Voice message is empty. Please try recording again.");
return;
}
const ext = outputMime.includes("ogg") ? "ogg" : outputMime.includes("mp4") ? "m4a" : "webm";
const file = new File([blob], `voice-${Date.now()}.${ext}`, { type: outputMime });
const waveform = await buildWaveformPoints(blob, 64);
await handleUpload(file, "voice", waveform);
};
recorderRef.current = recorder;
recorder.start(250);
recordingStartedAtRef.current = Date.now();
setRecordSeconds(0);
setRecordingState("recording");
emitTypingStopIfActive();
sendRealtimeChatEvent("recording_voice_start");
return true;
} catch {
setUploadError("Microphone access denied. Please allow microphone and retry.");
return false;
}
}
function stopRecord(send: boolean) {
if (recordingStateRef.current !== "idle") {
sendRealtimeChatEvent("recording_voice_stop");
}
sendVoiceOnStopRef.current = send;
pointerCancelArmedRef.current = false;
setDragHint("idle");
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
} catch {
// ignore recorder lifecycle race during quick chat switches/unmount
}
}
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);
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;
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");
}
function cancelSelectedFile() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setSelectedFiles([]);
setPreviewUrl(null);
setCaptionDraft("");
setSelectedType("file");
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);
});
}
function insertQuoteBlock() {
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 source = selected || "quote";
const quoted = source
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
const nextValue = `${text.slice(0, start)}${quoted}${text.slice(end)}`;
setText(nextValue);
if (activeChatId) {
setDraft(activeChatId, nextValue);
}
requestAnimationFrame(() => {
textarea.focus();
const pos = start + quoted.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}
{activeChatId && editingMessage ? (
<div className="mb-2 flex items-start justify-between rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
<div className="min-w-0">
<p className="font-semibold text-amber-200">Editing message</p>
<p className="truncate text-amber-100/80">{editingMessage.text || "[empty]"}</p>
</div>
<button
className="ml-2 rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => {
setEditingMessage(activeChatId, null);
setText(draftsByChat[activeChatId] ?? "");
}}
type="button"
>
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={() => insertFormatting("```\n", "\n```", "code")} type="button" title="Code block">
{"</>"}
</button>
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={insertQuoteBlock} type="button" title="Quote">
</button>
<button className="rounded px-2 py-1 text-xs hover:bg-slate-800" onClick={insertLink} type="button" title="Link">
🔗
</button>
</div>
) : null}
{showStickerMenu ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-200">Stickers</p>
<input
className="w-full max-w-xs rounded border border-slate-700/80 bg-slate-800/80 px-2 py-1 text-xs outline-none placeholder:text-slate-400 focus:border-sky-500"
onChange={(event) => setStickerQuery(event.target.value)}
placeholder="Search sticker"
value={stickerQuery}
/>
<div className="flex items-center gap-1">
<button
className={`rounded px-2 py-1 text-[11px] ${stickerTab === "all" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setStickerTab("all")}
type="button"
>
All
</button>
<button
className={`rounded px-2 py-1 text-[11px] ${stickerTab === "favorites" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setStickerTab("favorites")}
type="button"
>
Favorites
</button>
<button className="rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setShowStickerMenu(false)} type="button">
Close
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-2 md:grid-cols-8">
{STICKER_PRESETS
.filter((item) => (stickerTab === "favorites" ? favoriteStickers.has(item.url) : true))
.filter((item) => item.name.toLowerCase().includes(stickerQuery.trim().toLowerCase()))
.map((sticker) => (
<button
className="relative rounded-lg bg-slate-800/80 p-2 hover:bg-slate-700"
key={sticker.url}
onClick={() => void sendPresetMedia(sticker.url)}
title={sticker.name}
type="button"
>
<img alt={sticker.name} className="mx-auto h-9 w-9 object-contain" draggable={false} src={sticker.url} />
<span
className="absolute right-1 top-1 text-[10px]"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleStickerFavorite(sticker.url);
}}
>
{favoriteStickers.has(sticker.url) ? "★" : "☆"}
</span>
</button>
))}
</div>
{STICKER_PRESETS
.filter((item) => (stickerTab === "favorites" ? favoriteStickers.has(item.url) : true))
.filter((item) => item.name.toLowerCase().includes(stickerQuery.trim().toLowerCase())).length === 0 ? (
<p className="mt-2 text-xs text-slate-400">No stickers found</p>
) : null}
</div>
) : null}
{showGifMenu ? (
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-slate-200">GIF</p>
<input
className="w-full max-w-xs rounded border border-slate-700/80 bg-slate-800/80 px-2 py-1 text-xs outline-none placeholder:text-slate-400 focus:border-sky-500"
onChange={(event) => setGifQuery(event.target.value)}
placeholder="Search GIF"
value={gifQuery}
/>
<button className="rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setShowGifMenu(false)} type="button">
Close
</button>
</div>
<div className="mb-2 flex items-center gap-1">
<button
className={`rounded px-2 py-1 text-[11px] ${gifTab === "all" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setGifTab("all")}
type="button"
>
All
</button>
<button
className={`rounded px-2 py-1 text-[11px] ${gifTab === "favorites" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setGifTab("favorites")}
type="button"
>
Favorites
</button>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{(gifResults.length > 0 ? gifResults : GIF_PRESETS)
.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase()) || gifResults.length > 0)
.filter((item) => (gifTab === "favorites" ? favoriteGifs.has(item.url) : true))
.map((gif) => (
<button
className="relative overflow-hidden rounded-lg bg-slate-800/80 hover:bg-slate-700"
key={gif.url}
onClick={() => void sendPresetMedia(gif.url)}
title={gif.name}
type="button"
>
<img alt={gif.name} className="h-20 w-full object-cover md:h-24" draggable={false} src={gif.url} />
<span
className="absolute right-1 top-1 rounded bg-black/45 px-1 text-[10px] text-white"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleGifFavorite(gif.url);
}}
>
{favoriteGifs.has(gif.url) ? "★" : "☆"}
</span>
</button>
))}
</div>
{gifLoading ? <p className="mt-2 text-xs text-slate-400">Searching GIF...</p> : null}
{gifSearchError ? <p className="mt-2 text-xs text-amber-300">{gifSearchError}</p> : null}
</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-1.5 sm:gap-2">
<div className="relative">
<button
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-slate-700/85 text-sm font-semibold leading-none text-slate-200 hover:bg-slate-700 disabled:opacity-60 sm:h-[42px] sm:w-[42px]"
disabled={isUploading || recordingState !== "idle" || !canSendInActiveChat}
onClick={() => setShowAttachMenu((v) => !v)}
type="button"
>
<span className="hidden min-[520px]:inline">📎</span>
<span className="inline min-[520px]:hidden"></span>
</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>
<button
className="hidden w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800 max-[519px]:block"
onClick={() => {
setShowAttachMenu(false);
setShowGifMenu(false);
setShowFormatMenu(false);
setShowStickerMenu((v) => !v);
}}
type="button"
>
Stickers
</button>
<button
className="hidden w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800 max-[519px]:block"
onClick={() => {
setShowAttachMenu(false);
setShowStickerMenu(false);
setShowFormatMenu(false);
setShowGifMenu((v) => !v);
}}
type="button"
>
GIF
</button>
<button
className="hidden w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-slate-800 max-[519px]:block"
onClick={() => {
setShowAttachMenu(false);
setShowStickerMenu(false);
setShowGifMenu(false);
setShowFormatMenu((v) => !v);
}}
type="button"
>
Text format
</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="hidden h-10 w-10 items-center justify-center rounded-full bg-slate-700/85 text-xs font-semibold leading-none text-slate-200 hover:bg-slate-700 disabled:opacity-60 min-[440px]:inline-flex sm:h-[42px] sm:w-[42px]"
disabled={!canSendInActiveChat}
onClick={() => {
setShowGifMenu(false);
setShowStickerMenu((v) => !v);
}}
type="button"
title="Stickers"
>
🙂
</button>
<button
className="hidden h-10 w-10 items-center justify-center rounded-full bg-slate-700/85 text-xs font-semibold leading-none text-slate-200 hover:bg-slate-700 disabled:opacity-60 min-[500px]:inline-flex sm:h-[42px] sm:w-[42px]"
disabled={!canSendInActiveChat}
onClick={() => {
setShowStickerMenu(false);
setShowGifMenu((v) => !v);
}}
type="button"
title="GIF"
>
GIF
</button>
<button
className="hidden h-10 w-10 items-center justify-center rounded-full bg-slate-700/85 text-xs font-semibold leading-none text-slate-200 hover:bg-slate-700 min-[560px]:inline-flex sm:h-[42px] sm:w-[42px]"
onClick={() => setShowFormatMenu((v) => !v)}
type="button"
title="Text formatting"
disabled={!canSendInActiveChat}
>
Aa
</button>
<textarea
ref={textareaRef}
className="min-h-[42px] min-w-0 max-h-40 flex-1 resize-y rounded-2xl border border-slate-700/80 bg-slate-800/80 px-3 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500 sm:px-4"
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);
syncTypingForText(next);
}
}}
onBlur={() => {
emitTypingStopIfActive();
}}
/>
{hasTextToSend ? (
<button
className="inline-flex h-10 w-11 shrink-0 items-center justify-center rounded-full bg-sky-500 text-sm font-semibold leading-none text-slate-950 hover:bg-sky-400 disabled:opacity-60 sm:h-[42px] sm:w-[56px]"
disabled={recordingState !== "idle" || !activeChatId || !canSendInActiveChat}
onClick={handleSend}
type="button"
title={editingMessage ? "Save edit" : "Send message"}
>
{editingMessage ? "✓" : "↑"}
</button>
) : (
<button
className={`inline-flex h-10 w-11 shrink-0 items-center justify-center rounded-full text-sm font-semibold leading-none sm:h-[42px] sm:w-[56px] ${
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)}
/>
{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")}`;
}