1570 lines
59 KiB
TypeScript
1570 lines
59 KiB
TypeScript
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")}`;
|
||
}
|