From f369083b6a99e09e73d6c61021bbf106a61637ee Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 20:00:59 +0300 Subject: [PATCH] fix(realtime-ui): auto-expire stale typing/recording indicators --- web/src/hooks/useRealtime.ts | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 9f2a09b..3e6d1bd 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -18,6 +18,9 @@ export function useRealtime() { const typingByChat = useRef>>({}); const recordingVoiceByChat = useRef>>({}); const recordingVideoByChat = useRef>>({}); + const typingTimersRef = useRef>({}); + const recordingVoiceTimersRef = useRef>({}); + const recordingVideoTimersRef = useRef>({}); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const heartbeatIntervalRef = useRef(null); @@ -85,6 +88,25 @@ export function useRealtime() { } const chatStore = useChatStore.getState(); const authStore = useAuthStore.getState(); + const clearActivityTimer = (timers: Record, key: string) => { + const id = timers[key]; + if (id !== undefined) { + window.clearTimeout(id); + delete timers[key]; + } + }; + const armActivityTimer = ( + timers: Record, + key: string, + ttlMs: number, + onExpire: () => void + ) => { + clearActivityTimer(timers, key); + timers[key] = window.setTimeout(() => { + delete timers[key]; + onExpire(); + }, ttlMs); + }; if (event.event === "receive_message") { const chatId = Number(event.payload.chat_id); const message = event.payload.message as Message; @@ -163,6 +185,11 @@ export function useRealtime() { } typingByChat.current[chatId].add(userId); chatStore.setTypingUsers(chatId, [...typingByChat.current[chatId]]); + const key = `${chatId}:${userId}`; + armActivityTimer(typingTimersRef.current, key, 9000, () => { + typingByChat.current[chatId]?.delete(userId); + chatStore.setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]); + }); } if (event.event === "typing_stop") { const chatId = Number(event.payload.chat_id); @@ -172,6 +199,8 @@ export function useRealtime() { } typingByChat.current[chatId]?.delete(userId); chatStore.setTypingUsers(chatId, [...(typingByChat.current[chatId] ?? [])]); + const key = `${chatId}:${userId}`; + clearActivityTimer(typingTimersRef.current, key); } if (event.event === "recording_voice_start") { const chatId = Number(event.payload.chat_id); @@ -184,6 +213,11 @@ export function useRealtime() { } recordingVoiceByChat.current[chatId].add(userId); chatStore.setRecordingUsers(chatId, "voice", [...recordingVoiceByChat.current[chatId]]); + const key = `${chatId}:${userId}`; + armActivityTimer(recordingVoiceTimersRef.current, key, 12000, () => { + recordingVoiceByChat.current[chatId]?.delete(userId); + chatStore.setRecordingUsers(chatId, "voice", [...(recordingVoiceByChat.current[chatId] ?? [])]); + }); } if (event.event === "recording_voice_stop") { const chatId = Number(event.payload.chat_id); @@ -193,6 +227,8 @@ export function useRealtime() { } recordingVoiceByChat.current[chatId]?.delete(userId); chatStore.setRecordingUsers(chatId, "voice", [...(recordingVoiceByChat.current[chatId] ?? [])]); + const key = `${chatId}:${userId}`; + clearActivityTimer(recordingVoiceTimersRef.current, key); } if (event.event === "recording_video_start") { const chatId = Number(event.payload.chat_id); @@ -205,6 +241,11 @@ export function useRealtime() { } recordingVideoByChat.current[chatId].add(userId); chatStore.setRecordingUsers(chatId, "video", [...recordingVideoByChat.current[chatId]]); + const key = `${chatId}:${userId}`; + armActivityTimer(recordingVideoTimersRef.current, key, 12000, () => { + recordingVideoByChat.current[chatId]?.delete(userId); + chatStore.setRecordingUsers(chatId, "video", [...(recordingVideoByChat.current[chatId] ?? [])]); + }); } if (event.event === "recording_video_stop") { const chatId = Number(event.payload.chat_id); @@ -214,6 +255,8 @@ export function useRealtime() { } recordingVideoByChat.current[chatId]?.delete(userId); chatStore.setRecordingUsers(chatId, "video", [...(recordingVideoByChat.current[chatId] ?? [])]); + const key = `${chatId}:${userId}`; + clearActivityTimer(recordingVideoTimersRef.current, key); } if (event.event === "message_delivered") { const chatId = Number(event.payload.chat_id); @@ -331,6 +374,12 @@ export function useRealtime() { typingByChat.current = {}; recordingVoiceByChat.current = {}; recordingVideoByChat.current = {}; + Object.values(typingTimersRef.current).forEach((id) => window.clearTimeout(id)); + Object.values(recordingVoiceTimersRef.current).forEach((id) => window.clearTimeout(id)); + Object.values(recordingVideoTimersRef.current).forEach((id) => window.clearTimeout(id)); + typingTimersRef.current = {}; + recordingVoiceTimersRef.current = {}; + recordingVideoTimersRef.current = {}; useChatStore.setState({ typingByChat: {}, recordingVoiceByChat: {}, recordingVideoByChat: {} }); window.removeEventListener("focus", onFocusOrVisible); document.removeEventListener("visibilitychange", onFocusOrVisible);