Files
Messenger/web/src/components/MessageComposer.tsx
benya f95a0e9727
All checks were successful
CI / test (push) Successful in 27s
feat: improve chat realtime and media composer UX
- add media preview and upload confirmation for image/video

- add upload progress tracking for presigned uploads

- keep voice recording/upload flow with better UI states

- include related realtime/chat updates currently in working tree
2026-03-07 22:46:04 +03:00

242 lines
8.2 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { attachFile, requestUploadUrl, sendMessage, 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 prependMessage = useChatStore((s) => s.prependMessage);
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(() => {
return () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
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()) {
return;
}
const message = await sendMessage(activeChatId, text.trim(), "text");
prependMessage(activeChatId, message);
setText("");
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
}
async function handleUpload(file: File, messageType: "file" | "image" | "video" | "audio" | "voice" = "file") {
if (!activeChatId) {
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
const message = await sendMessage(activeChatId, upload.file_url, messageType);
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
prependMessage(activeChatId, message);
} catch {
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";
}
async function startRecord() {
try {
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.");
}
}
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;
}
await handleUpload(selectedFile, 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 bg-panel p-3">
<div className="mb-2 flex gap-2">
<input
className="flex-1 rounded bg-slate-800 px-3 py-2"
placeholder="Type message..."
value={text}
onChange={(e) => {
setText(e.target.value);
if (activeChatId) {
const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_start", payload: { chat_id: activeChatId } }));
}
}}
/>
<button className="rounded bg-accent px-3 py-2 font-semibold text-black" onClick={handleSend}>
Send
</button>
</div>
{selectedFile ? (
<div className="mb-2 rounded border border-slate-700 bg-slate-900 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 bg-accent px-3 py-1 font-semibold text-black disabled:opacity-50"
onClick={() => void sendSelectedFile()}
disabled={isUploading}
>
Send media
</button>
<button className="rounded 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">
<label className="cursor-pointer rounded bg-slate-700 px-3 py-1">
Upload
<input
className="hidden"
type="file"
disabled={isUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
selectFile(file);
}
e.currentTarget.value = "";
}}
/>
</label>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={startRecord} disabled={isUploading || isRecording}>
{isRecording ? "Recording..." : "Record Voice"}
</button>
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
Stop
</button>
</div>
</div>
);
}