feat: improve chat realtime and media composer UX
All checks were successful
CI / test (push) Successful in 27s
All checks were successful
CI / test (push) Successful in 27s
- 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
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { attachFile, requestUploadUrl, sendMessage } from "../api/chats";
|
||||
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";
|
||||
@@ -12,6 +12,21 @@ export function MessageComposer() {
|
||||
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) {
|
||||
@@ -40,34 +55,109 @@ export function MessageComposer() {
|
||||
if (!activeChatId) {
|
||||
return;
|
||||
}
|
||||
const upload = await requestUploadUrl(file);
|
||||
await fetch(upload.upload_url, {
|
||||
method: "PUT",
|
||||
headers: upload.required_headers,
|
||||
body: file
|
||||
});
|
||||
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);
|
||||
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() {
|
||||
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" });
|
||||
await handleUpload(file, "voice");
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
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 (
|
||||
@@ -89,24 +179,60 @@ export function MessageComposer() {
|
||||
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) {
|
||||
void handleUpload(file, "file");
|
||||
selectFile(file);
|
||||
}
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button className="rounded bg-slate-700 px-3 py-1" onClick={startRecord}>
|
||||
Record Voice
|
||||
<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" onClick={stopRecord}>
|
||||
<button className="rounded bg-slate-700 px-3 py-1 disabled:opacity-50" onClick={stopRecord} disabled={!isRecording}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user