feat: improve voice recording UX and realtime state reconciliation
All checks were successful
CI / test (push) Successful in 20s
All checks were successful
CI / test (push) Successful in 20s
This commit is contained in:
@@ -39,6 +39,7 @@ export function MessageComposer() {
|
|||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
const [selectedType, setSelectedType] = useState<"file" | "image" | "video" | "audio">("file");
|
||||||
|
const [sendAsCircle, setSendAsCircle] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||||
const [showFormatMenu, setShowFormatMenu] = useState(false);
|
const [showFormatMenu, setShowFormatMenu] = useState(false);
|
||||||
@@ -74,6 +75,9 @@ export function MessageComposer() {
|
|||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
|
if (recordingStateRef.current !== "idle") {
|
||||||
|
stopRecord(false);
|
||||||
|
}
|
||||||
if (pointerMoveHandlerRef.current) {
|
if (pointerMoveHandlerRef.current) {
|
||||||
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,31 @@ export function MessageComposer() {
|
|||||||
};
|
};
|
||||||
}, [previewUrl]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (recordingState === "idle") {
|
if (recordingState === "idle") {
|
||||||
return;
|
return;
|
||||||
@@ -173,7 +202,7 @@ export function MessageComposer() {
|
|||||||
|
|
||||||
async function handleUpload(
|
async function handleUpload(
|
||||||
file: File,
|
file: File,
|
||||||
messageType: "file" | "image" | "video" | "audio" | "voice" = "file",
|
messageType: "file" | "image" | "video" | "audio" | "voice" | "circle_video" = "file",
|
||||||
waveformPoints?: number[] | null
|
waveformPoints?: number[] | null
|
||||||
) {
|
) {
|
||||||
if (!activeChatId || !me) {
|
if (!activeChatId || !me) {
|
||||||
@@ -278,6 +307,10 @@ export function MessageComposer() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (!("mediaDevices" in navigator) || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
setUploadError("Microphone is not supported in this browser.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (navigator.permissions && navigator.permissions.query) {
|
if (navigator.permissions && navigator.permissions.query) {
|
||||||
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
|
const permission = await navigator.permissions.query({ name: "microphone" as PermissionName });
|
||||||
if (permission.state === "denied") {
|
if (permission.state === "denied") {
|
||||||
@@ -335,6 +368,14 @@ export function MessageComposer() {
|
|||||||
recorderRef.current = null;
|
recorderRef.current = null;
|
||||||
setRecordingState("idle");
|
setRecordingState("idle");
|
||||||
setRecordSeconds(0);
|
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>) {
|
async function onMicPointerDown(event: PointerEvent<HTMLButtonElement>) {
|
||||||
@@ -383,7 +424,7 @@ export function MessageComposer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerUp = () => {
|
const finishPointerSession = () => {
|
||||||
if (pointerMoveHandlerRef.current) {
|
if (pointerMoveHandlerRef.current) {
|
||||||
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
window.removeEventListener("pointermove", pointerMoveHandlerRef.current);
|
||||||
}
|
}
|
||||||
@@ -400,10 +441,19 @@ export function MessageComposer() {
|
|||||||
setDragHint("idle");
|
setDragHint("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
finishPointerSession();
|
||||||
|
};
|
||||||
|
const onPointerCancel = () => {
|
||||||
|
pointerCancelArmedRef.current = true;
|
||||||
|
finishPointerSession();
|
||||||
|
};
|
||||||
|
|
||||||
pointerMoveHandlerRef.current = onPointerMove;
|
pointerMoveHandlerRef.current = onPointerMove;
|
||||||
pointerUpHandlerRef.current = onPointerUp;
|
pointerUpHandlerRef.current = onPointerUp;
|
||||||
window.addEventListener("pointermove", onPointerMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
window.addEventListener("pointerup", onPointerUp);
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
window.addEventListener("pointercancel", onPointerCancel, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(file: File) {
|
function selectFile(file: File) {
|
||||||
@@ -412,6 +462,7 @@ export function MessageComposer() {
|
|||||||
setShowAttachMenu(false);
|
setShowAttachMenu(false);
|
||||||
const fileType = inferType(file);
|
const fileType = inferType(file);
|
||||||
setSelectedType(fileType);
|
setSelectedType(fileType);
|
||||||
|
setSendAsCircle(false);
|
||||||
if (previewUrl) {
|
if (previewUrl) {
|
||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
@@ -427,7 +478,8 @@ export function MessageComposer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uploadFile = await prepareFileForUpload(selectedFile, selectedType);
|
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) {
|
if (captionDraft.trim() && activeChatId && me) {
|
||||||
const clientMessageId = makeClientMessageId();
|
const clientMessageId = makeClientMessageId();
|
||||||
const textValue = captionDraft.trim();
|
const textValue = captionDraft.trim();
|
||||||
@@ -446,6 +498,7 @@ export function MessageComposer() {
|
|||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setCaptionDraft("");
|
setCaptionDraft("");
|
||||||
setSelectedType("file");
|
setSelectedType("file");
|
||||||
|
setSendAsCircle(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +510,7 @@ export function MessageComposer() {
|
|||||||
setPreviewUrl(null);
|
setPreviewUrl(null);
|
||||||
setCaptionDraft("");
|
setCaptionDraft("");
|
||||||
setSelectedType("file");
|
setSelectedType("file");
|
||||||
|
setSendAsCircle(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
}
|
}
|
||||||
@@ -728,6 +782,16 @@ export function MessageComposer() {
|
|||||||
value={captionDraft}
|
value={captionDraft}
|
||||||
onChange={(event) => setCaptionDraft(event.target.value)}
|
onChange={(event) => setCaptionDraft(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{selectedType === "video" ? (
|
||||||
|
<label className="mb-2 flex items-center gap-2 text-xs text-slate-300">
|
||||||
|
<input
|
||||||
|
checked={sendAsCircle}
|
||||||
|
onChange={(event) => setSendAsCircle(event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Send as video message (circle)
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function useRealtime() {
|
|||||||
const manualCloseRef = useRef(false);
|
const manualCloseRef = useRef(false);
|
||||||
const notificationPermissionRequestedRef = useRef(false);
|
const notificationPermissionRequestedRef = useRef(false);
|
||||||
const reloadChatsTimerRef = useRef<number | null>(null);
|
const reloadChatsTimerRef = useRef<number | null>(null);
|
||||||
|
const reconcileIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const wsUrl = useMemo(() => {
|
const wsUrl = useMemo(() => {
|
||||||
return accessToken ? buildWsUrl(accessToken) : null;
|
return accessToken ? buildWsUrl(accessToken) : null;
|
||||||
@@ -66,11 +67,7 @@ export function useRealtime() {
|
|||||||
ws.close();
|
ws.close();
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
const store = useChatStore.getState();
|
void reconcileState();
|
||||||
void store.loadChats();
|
|
||||||
if (store.activeChatId) {
|
|
||||||
void store.loadMessages(store.activeChatId);
|
|
||||||
}
|
|
||||||
if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) {
|
if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) {
|
||||||
notificationPermissionRequestedRef.current = true;
|
notificationPermissionRequestedRef.current = true;
|
||||||
void Notification.requestPermission();
|
void Notification.requestPermission();
|
||||||
@@ -198,6 +195,10 @@ export function useRealtime() {
|
|||||||
window.clearInterval(watchdogIntervalRef.current);
|
window.clearInterval(watchdogIntervalRef.current);
|
||||||
watchdogIntervalRef.current = null;
|
watchdogIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reconcileIntervalRef.current !== null) {
|
||||||
|
window.clearInterval(reconcileIntervalRef.current);
|
||||||
|
reconcileIntervalRef.current = null;
|
||||||
|
}
|
||||||
if (manualCloseRef.current) {
|
if (manualCloseRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,9 +210,24 @@ export function useRealtime() {
|
|||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
ws.close();
|
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();
|
connect();
|
||||||
|
window.addEventListener("focus", onFocusOrVisible);
|
||||||
|
document.addEventListener("visibilitychange", onFocusOrVisible);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
manualCloseRef.current = true;
|
manualCloseRef.current = true;
|
||||||
@@ -223,6 +239,10 @@ export function useRealtime() {
|
|||||||
window.clearInterval(watchdogIntervalRef.current);
|
window.clearInterval(watchdogIntervalRef.current);
|
||||||
watchdogIntervalRef.current = null;
|
watchdogIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (reconcileIntervalRef.current !== null) {
|
||||||
|
window.clearInterval(reconcileIntervalRef.current);
|
||||||
|
reconcileIntervalRef.current = null;
|
||||||
|
}
|
||||||
if (reconnectTimeoutRef.current !== null) {
|
if (reconnectTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(reconnectTimeoutRef.current);
|
window.clearTimeout(reconnectTimeoutRef.current);
|
||||||
reconnectTimeoutRef.current = null;
|
reconnectTimeoutRef.current = null;
|
||||||
@@ -235,9 +255,20 @@ export function useRealtime() {
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
typingByChat.current = {};
|
typingByChat.current = {};
|
||||||
useChatStore.setState({ typingByChat: {} });
|
useChatStore.setState({ typingByChat: {} });
|
||||||
|
window.removeEventListener("focus", onFocusOrVisible);
|
||||||
|
document.removeEventListener("visibilitychange", onFocusOrVisible);
|
||||||
};
|
};
|
||||||
}, [wsUrl, meId]);
|
}, [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() {
|
function scheduleReloadChats() {
|
||||||
if (reloadChatsTimerRef.current !== null) {
|
if (reloadChatsTimerRef.current !== null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user