From 2af4588688790efa9ed990cae7a8a04fa02cf97e Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 12:44:15 +0300 Subject: [PATCH] feat: improve voice recording UX and realtime state reconciliation --- web/src/components/MessageComposer.tsx | 70 ++++++++++++++++++++++++-- web/src/hooks/useRealtime.ts | 41 +++++++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index 5aa8dc3..abe6b48 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -39,6 +39,7 @@ export function MessageComposer() { const [uploadError, setUploadError] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file"); + const [sendAsCircle, setSendAsCircle] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showFormatMenu, setShowFormatMenu] = useState(false); @@ -74,6 +75,9 @@ export function MessageComposer() { if (previewUrl) { URL.revokeObjectURL(previewUrl); } + if (recordingStateRef.current !== "idle") { + stopRecord(false); + } if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } @@ -83,6 +87,31 @@ export function MessageComposer() { }; }, [previewUrl]); + useEffect(() => { + if (!activeChatId && recordingStateRef.current !== "idle") { + stopRecord(false); + } + }, [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; @@ -173,7 +202,7 @@ export function MessageComposer() { async function handleUpload( file: File, - messageType: "file" | "image" | "video" | "audio" | "voice" = "file", + messageType: "file" | "image" | "video" | "audio" | "voice" | "circle_video" = "file", waveformPoints?: number[] | null ) { if (!activeChatId || !me) { @@ -278,6 +307,10 @@ export function MessageComposer() { 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") { @@ -335,6 +368,14 @@ export function MessageComposer() { 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) { @@ -383,7 +424,7 @@ export function MessageComposer() { } }; - const onPointerUp = () => { + const finishPointerSession = () => { if (pointerMoveHandlerRef.current) { window.removeEventListener("pointermove", pointerMoveHandlerRef.current); } @@ -400,10 +441,19 @@ export function MessageComposer() { 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 selectFile(file: File) { @@ -412,6 +462,7 @@ export function MessageComposer() { setShowAttachMenu(false); const fileType = inferType(file); setSelectedType(fileType); + setSendAsCircle(false); if (previewUrl) { URL.revokeObjectURL(previewUrl); } @@ -427,7 +478,8 @@ export function MessageComposer() { return; } const uploadFile = await prepareFileForUpload(selectedFile, selectedType); - await handleUpload(uploadFile, selectedType); + const messageType = selectedType === "video" && sendAsCircle ? "circle_video" : selectedType; + await handleUpload(uploadFile, messageType); if (captionDraft.trim() && activeChatId && me) { const clientMessageId = makeClientMessageId(); const textValue = captionDraft.trim(); @@ -446,6 +498,7 @@ export function MessageComposer() { setPreviewUrl(null); setCaptionDraft(""); setSelectedType("file"); + setSendAsCircle(false); setUploadProgress(0); } @@ -457,6 +510,7 @@ export function MessageComposer() { setPreviewUrl(null); setCaptionDraft(""); setSelectedType("file"); + setSendAsCircle(false); setUploadProgress(0); setUploadError(null); } @@ -728,6 +782,16 @@ export function MessageComposer() { value={captionDraft} onChange={(event) => setCaptionDraft(event.target.value)} /> + {selectedType === "video" ? ( + + ) : null} {isUploading ? (
diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 543641d..e1e5739 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -25,6 +25,7 @@ export function useRealtime() { const manualCloseRef = useRef(false); const notificationPermissionRequestedRef = useRef(false); const reloadChatsTimerRef = useRef(null); + const reconcileIntervalRef = useRef(null); const wsUrl = useMemo(() => { return accessToken ? buildWsUrl(accessToken) : null; @@ -66,11 +67,7 @@ export function useRealtime() { ws.close(); } }, 15000); - const store = useChatStore.getState(); - void store.loadChats(); - if (store.activeChatId) { - void store.loadMessages(store.activeChatId); - } + void reconcileState(); if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) { notificationPermissionRequestedRef.current = true; void Notification.requestPermission(); @@ -198,6 +195,10 @@ export function useRealtime() { window.clearInterval(watchdogIntervalRef.current); watchdogIntervalRef.current = null; } + if (reconcileIntervalRef.current !== null) { + window.clearInterval(reconcileIntervalRef.current); + reconcileIntervalRef.current = null; + } if (manualCloseRef.current) { return; } @@ -209,9 +210,24 @@ export function useRealtime() { ws.onerror = () => { ws.close(); }; + + if (reconcileIntervalRef.current !== null) { + window.clearInterval(reconcileIntervalRef.current); + } + reconcileIntervalRef.current = window.setInterval(() => { + void reconcileState(); + }, 60000); + }; + + const onFocusOrVisible = () => { + if (document.visibilityState === "visible") { + void reconcileState(); + } }; connect(); + window.addEventListener("focus", onFocusOrVisible); + document.addEventListener("visibilitychange", onFocusOrVisible); return () => { manualCloseRef.current = true; @@ -223,6 +239,10 @@ export function useRealtime() { window.clearInterval(watchdogIntervalRef.current); watchdogIntervalRef.current = null; } + if (reconcileIntervalRef.current !== null) { + window.clearInterval(reconcileIntervalRef.current); + reconcileIntervalRef.current = null; + } if (reconnectTimeoutRef.current !== null) { window.clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; @@ -235,9 +255,20 @@ export function useRealtime() { wsRef.current = null; typingByChat.current = {}; useChatStore.setState({ typingByChat: {} }); + window.removeEventListener("focus", onFocusOrVisible); + document.removeEventListener("visibilitychange", onFocusOrVisible); }; }, [wsUrl, meId]); + async function reconcileState() { + const storeBefore = useChatStore.getState(); + await storeBefore.loadChats(); + const storeAfter = useChatStore.getState(); + if (storeAfter.activeChatId) { + await storeAfter.loadMessages(storeAfter.activeChatId); + } + } + function scheduleReloadChats() { if (reloadChatsTimerRef.current !== null) { return;