Some checks failed
CI / test (push) Failing after 17s
- store unsent draft text per chat in zustand - restore draft when switching chats - clear draft after successful send
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { attachFile, requestUploadUrl, sendMessageWithClientId, uploadToPresignedUrl } from "../api/chats";
|
|
import { useAuthStore } from "../store/authStore";
|
|
import { useChatStore } from "../store/chatStore";
|
|
import { buildWsUrl } from "../utils/ws";
|
|
|
|
export function MessageComposer() {
|
|
const activeChatId = useChatStore((s) => s.activeChatId);
|
|
const me = useAuthStore((s) => s.me);
|
|
const draftsByChat = useChatStore((s) => s.draftsByChat);
|
|
const setDraft = useChatStore((s) => s.setDraft);
|
|
const clearDraft = useChatStore((s) => s.clearDraft);
|
|
const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage);
|
|
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
|
|
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
|
|
const replyToByChat = useChatStore((s) => s.replyToByChat);
|
|
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
|
|
const accessToken = useAuthStore((s) => s.accessToken);
|
|
const [text, setText] = useState("");
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<BlobPart[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState(0);
|
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!activeChatId) {
|
|
if (text !== "") {
|
|
setText("");
|
|
}
|
|
return;
|
|
}
|
|
const draft = draftsByChat[activeChatId] ?? "";
|
|
if (draft !== text) {
|
|
setText(draft);
|
|
}
|
|
}, [activeChatId, draftsByChat, text]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewUrl) {
|
|
URL.revokeObjectURL(previewUrl);
|
|
}
|
|
};
|
|
}, [previewUrl]);
|
|
|
|
function makeClientMessageId(): string {
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function getWs(): WebSocket | null {
|
|
if (!accessToken || !activeChatId) {
|
|
return null;
|
|
}
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
return wsRef.current;
|
|
}
|
|
const wsUrl = buildWsUrl(accessToken);
|
|
wsRef.current = new WebSocket(wsUrl);
|
|
return wsRef.current;
|
|
}
|
|
|
|
async function handleSend() {
|
|
if (!activeChatId || !text.trim() || !me) {
|
|
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 {
|
|
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId);
|
|
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
|
setText("");
|
|
clearDraft(activeChatId);
|
|
setReplyToMessage(activeChatId, null);
|
|
const ws = getWs();
|
|
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
|
|
} catch {
|
|
removeOptimisticMessage(activeChatId, clientMessageId);
|
|
setUploadError("Message send failed. Please try again.");
|
|
}
|
|
}
|
|
|
|
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
|
|
if (!activeChatId || !me) {
|
|
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);
|
|
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
|
|
confirmMessageByClientId(activeChatId, clientMessageId, message);
|
|
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() {
|
|
try {
|
|
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;
|
|
}
|
|
}
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const recorder = new MediaRecorder(stream);
|
|
chunksRef.current = [];
|
|
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
|
|
recorder.onstop = async () => {
|
|
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
|
const file = new File([blob], `voice-${Date.now()}.webm`, { type: "audio/webm" });
|
|
setIsRecording(false);
|
|
await handleUpload(file, "voice");
|
|
};
|
|
recorderRef.current = recorder;
|
|
recorder.start();
|
|
setIsRecording(true);
|
|
} catch {
|
|
setUploadError("Microphone access denied. Please allow microphone and retry.");
|
|
}
|
|
}
|
|
|
|
function stopRecord() {
|
|
recorderRef.current?.stop();
|
|
recorderRef.current = null;
|
|
setIsRecording(false);
|
|
}
|
|
|
|
function selectFile(file: File) {
|
|
setUploadError(null);
|
|
setSelectedFile(file);
|
|
const fileType = inferType(file);
|
|
setSelectedType(fileType);
|
|
if (previewUrl) {
|
|
URL.revokeObjectURL(previewUrl);
|
|
}
|
|
if (fileType === "image" || fileType === "video") {
|
|
setPreviewUrl(URL.createObjectURL(file));
|
|
} else {
|
|
setPreviewUrl(null);
|
|
}
|
|
}
|
|
|
|
async function sendSelectedFile() {
|
|
if (!selectedFile) {
|
|
return;
|
|
}
|
|
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
|
|
await handleUpload(uploadFile, selectedType);
|
|
if (previewUrl) {
|
|
URL.revokeObjectURL(previewUrl);
|
|
}
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
setSelectedType("file");
|
|
setUploadProgress(0);
|
|
}
|
|
|
|
function cancelSelectedFile() {
|
|
if (previewUrl) {
|
|
URL.revokeObjectURL(previewUrl);
|
|
}
|
|
setSelectedFile(null);
|
|
setPreviewUrl(null);
|
|
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`;
|
|
}
|
|
|
|
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}
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
|
|
+
|
|
<input
|
|
className="hidden"
|
|
type="file"
|
|
disabled={isUploading}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
selectFile(file);
|
|
}
|
|
e.currentTarget.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
<input
|
|
className="flex-1 rounded-full border border-slate-700/80 bg-slate-800/80 px-4 py-2.5 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
|
placeholder="Write a message..."
|
|
value={text}
|
|
onChange={(e) => {
|
|
const next = e.target.value;
|
|
setText(next);
|
|
if (activeChatId) {
|
|
setDraft(activeChatId, next);
|
|
}
|
|
if (activeChatId) {
|
|
const ws = getWs();
|
|
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
|
|
}
|
|
}}
|
|
/>
|
|
<button className="rounded-full bg-sky-500 px-4 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400" onClick={handleSend}>
|
|
Send
|
|
</button>
|
|
</div>
|
|
{selectedFile ? (
|
|
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm">
|
|
<div className="mb-2 font-semibold">Ready to send</div>
|
|
<div className="mb-1 break-all text-slate-300">{selectedFile.name}</div>
|
|
<div className="mb-2 text-xs text-slate-400">{formatBytes(selectedFile.size)}</div>
|
|
{previewUrl && selectedType === "image" ? (
|
|
<img className="mb-2 max-h-56 rounded object-contain" src={previewUrl} alt={selectedFile.name} />
|
|
) : null}
|
|
{previewUrl && selectedType === "video" ? (
|
|
<video className="mb-2 max-h-56 w-full rounded" src={previewUrl} controls muted />
|
|
) : null}
|
|
{isUploading ? (
|
|
<div className="mb-2">
|
|
<div className="mb-1 text-xs text-slate-300">Uploading: {uploadProgress}%</div>
|
|
<div className="h-2 rounded bg-slate-700">
|
|
<div className="h-2 rounded bg-accent transition-all" style={{ width: `${uploadProgress}%` }} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="flex gap-2">
|
|
<button
|
|
className="rounded-lg bg-sky-500 px-3 py-1 font-semibold text-slate-950 disabled:opacity-50"
|
|
onClick={() => void sendSelectedFile()}
|
|
disabled={isUploading}
|
|
>
|
|
Send media
|
|
</button>
|
|
<button className="rounded-lg bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={cancelSelectedFile} disabled={isUploading}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
|
|
{isRecording ? "Recording..." : "Record Voice"}
|
|
</button>
|
|
<button className="rounded-lg bg-slate-700/90 px-3 py-1.5 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
|
|
Stop
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|