import { useEffect, useMemo, useRef } from "react"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import type { Message } from "../chat/types"; import { getAppPreferences } from "../utils/preferences"; import { buildNotificationPreview, showNotificationViaServiceWorker } from "../utils/webNotifications"; import { buildWsUrl } from "../utils/ws"; interface RealtimeEnvelope { event: string; payload: Record; timestamp: string; } export function useRealtime() { const accessToken = useAuthStore((s) => s.accessToken); const meId = useAuthStore((s) => s.me?.id ?? null); 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); const watchdogIntervalRef = useRef(null); const lastPongAtRef = useRef(Date.now()); const reconnectAttemptsRef = useRef(0); const manualCloseRef = useRef(false); const notificationPermissionRequestedRef = useRef(false); const reloadChatsTimerRef = useRef(null); const reconcileIntervalRef = useRef(null); const wsUrl = useMemo(() => { return accessToken ? buildWsUrl(accessToken) : null; }, [accessToken]); useEffect(() => { if (!wsUrl) { manualCloseRef.current = true; if (reconnectTimeoutRef.current !== null) { window.clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } wsRef.current?.close(); wsRef.current = null; return; } manualCloseRef.current = false; const connect = () => { const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { reconnectAttemptsRef.current = 0; lastPongAtRef.current = Date.now(); if (heartbeatIntervalRef.current !== null) { window.clearInterval(heartbeatIntervalRef.current); } if (watchdogIntervalRef.current !== null) { window.clearInterval(watchdogIntervalRef.current); } heartbeatIntervalRef.current = window.setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ event: "ping", payload: {} })); } }, 20000); watchdogIntervalRef.current = window.setInterval(() => { if (Date.now() - lastPongAtRef.current > 65000 && ws.readyState === WebSocket.OPEN) { ws.close(); } }, 15000); void reconcileState(); if ("Notification" in window && Notification.permission === "default" && !notificationPermissionRequestedRef.current) { notificationPermissionRequestedRef.current = true; void Notification.requestPermission(); } }; ws.onmessage = (messageEvent) => { let event: RealtimeEnvelope; try { event = JSON.parse(messageEvent.data) as RealtimeEnvelope; } catch { return; } 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; const clientMessageId = event.payload.client_message_id as string | undefined; if (!Number.isFinite(chatId) || !message?.id) { return; } let wasInserted = false; if (clientMessageId && message.sender_id === authStore.me?.id) { chatStore.confirmMessageByClientId(chatId, clientMessageId, message); } else { wasInserted = chatStore.prependMessage(chatId, message); } if (message.sender_id !== authStore.me?.id) { ws.send(JSON.stringify({ event: "message_delivered", payload: { chat_id: chatId, message_id: message.id } })); if (chatId === chatStore.activeChatId) { ws.send(JSON.stringify({ event: "message_read", payload: { chat_id: chatId, message_id: message.id } })); chatStore.clearUnread(chatId); } else if (wasInserted) { chatStore.incrementUnread(chatId, hasMentionForUser(message.text, authStore.me?.username ?? null)); } maybeShowBrowserNotification(chatId, message, chatStore.activeChatId, authStore.me?.username ?? null); } if (!chatStore.chats.some((chat) => chat.id === chatId)) { scheduleReloadChats(); } else { scheduleReloadChats(); } } if (event.event === "message_updated") { const chatId = Number(event.payload.chat_id); const message = event.payload.message as Message; if (!Number.isFinite(chatId) || !message?.id) { return; } chatStore.upsertMessage(chatId, message); scheduleReloadChats(); } if (event.event === "message_deleted") { const chatId = Number(event.payload.chat_id); const messageId = Number(event.payload.message_id); if (!Number.isFinite(chatId) || !Number.isFinite(messageId)) { return; } chatStore.removeMessage(chatId, messageId); scheduleReloadChats(); } if (event.event === "chat_updated") { const chatId = Number(event.payload.chat_id); if (Number.isFinite(chatId)) { window.dispatchEvent(new CustomEvent("bm:chat-updated", { detail: { chatId } })); scheduleReloadChats(); if (chatStore.activeChatId === chatId || (chatStore.messagesByChat[chatId]?.length ?? 0) > 0) { void chatStore.loadMessages(chatId, { markRead: false }); } } } if (event.event === "chat_deleted") { const chatId = Number(event.payload.chat_id); if (Number.isFinite(chatId)) { chatStore.removeChat(chatId); scheduleReloadChats(); } } if (event.event === "pong") { lastPongAtRef.current = Date.now(); } if (event.event === "typing_start") { const chatId = Number(event.payload.chat_id); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId) || userId === authStore.me?.id) { return; } if (!typingByChat.current[chatId]) { typingByChat.current[chatId] = new Set(); } 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); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId)) { return; } 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); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId) || userId === authStore.me?.id) { return; } if (!recordingVoiceByChat.current[chatId]) { recordingVoiceByChat.current[chatId] = new Set(); } 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); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId)) { return; } 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); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId) || userId === authStore.me?.id) { return; } if (!recordingVideoByChat.current[chatId]) { recordingVideoByChat.current[chatId] = new Set(); } 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); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(userId)) { return; } 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); const messageId = Number(event.payload.message_id); const lastDeliveredMessageId = Number(event.payload.last_delivered_message_id); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) { return; } if (userId !== authStore.me?.id) { const maxId = Number.isFinite(lastDeliveredMessageId) ? lastDeliveredMessageId : messageId; chatStore.setMessageDeliveryStatusUpTo(chatId, maxId, "delivered", authStore.me?.id ?? -1); } } if (event.event === "message_read") { const chatId = Number(event.payload.chat_id); const messageId = Number(event.payload.message_id); const lastReadMessageId = Number(event.payload.last_read_message_id); const userId = Number(event.payload.user_id); if (!Number.isFinite(chatId) || !Number.isFinite(messageId) || !Number.isFinite(userId)) { return; } if (userId !== authStore.me?.id) { const maxId = Number.isFinite(lastReadMessageId) ? lastReadMessageId : messageId; chatStore.setMessageDeliveryStatusUpTo(chatId, maxId, "read", authStore.me?.id ?? -1); } } if (event.event === "user_online" || event.event === "user_offline") { const chatId = Number(event.payload.chat_id); const userId = Number(event.payload.user_id); const isOnline = Boolean(event.payload.is_online); const lastSeenAtRaw = event.payload.last_seen_at; const lastSeenAt = typeof lastSeenAtRaw === "string" ? lastSeenAtRaw : undefined; if (!Number.isFinite(chatId) || !Number.isFinite(userId)) { return; } if (userId !== authStore.me?.id) { chatStore.applyPresenceEvent(chatId, userId, isOnline, lastSeenAt); } } }; ws.onclose = (closeEvent) => { if (heartbeatIntervalRef.current !== null) { window.clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } if (watchdogIntervalRef.current !== null) { window.clearInterval(watchdogIntervalRef.current); watchdogIntervalRef.current = null; } if (reconcileIntervalRef.current !== null) { window.clearInterval(reconcileIntervalRef.current); reconcileIntervalRef.current = null; } if (closeEvent.code === 4401 || closeEvent.code === 1008) { manualCloseRef.current = true; useAuthStore.getState().logout(); return; } if (manualCloseRef.current) { return; } reconnectAttemptsRef.current += 1; const delay = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttemptsRef.current, 4)); reconnectTimeoutRef.current = window.setTimeout(connect, delay); }; 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; if (heartbeatIntervalRef.current !== null) { window.clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } if (watchdogIntervalRef.current !== null) { 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; } if (reloadChatsTimerRef.current !== null) { window.clearTimeout(reloadChatsTimerRef.current); reloadChatsTimerRef.current = null; } wsRef.current?.close(); wsRef.current = null; 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); }; }, [wsUrl, meId]); async function reconcileState() { const storeBefore = useChatStore.getState(); await storeBefore.loadChats(); const storeAfter = useChatStore.getState(); const loadedChatIds = Object.keys(storeAfter.messagesByChat) .map((value) => Number(value)) .filter((value) => Number.isFinite(value) && (storeAfter.messagesByChat[value]?.length ?? 0) > 0); const uniqueChatIds = new Set(loadedChatIds); if (storeAfter.activeChatId) { uniqueChatIds.add(storeAfter.activeChatId); } for (const chatId of uniqueChatIds) { await storeAfter.loadMessages(chatId, { markRead: false }); } } function scheduleReloadChats() { if (reloadChatsTimerRef.current !== null) { return; } reloadChatsTimerRef.current = window.setTimeout(() => { reloadChatsTimerRef.current = null; void useChatStore.getState().loadChats(); }, 250); } return null; } function maybeShowBrowserNotification( chatId: number, message: Message, activeChatId: number | null, currentUsername: string | null ): void { const prefs = getAppPreferences(); if (!prefs.webNotifications) { return; } if (!("Notification" in window) || Notification.permission !== "granted") { return; } if (!document.hidden && activeChatId === chatId) { return; } const chat = useChatStore.getState().chats.find((item) => item.id === chatId); const isMention = hasMentionForUser(message.text, currentUsername); if (!isMention && chat?.muted) { return; } if (!isMention && chat?.type === "private" && !prefs.privateNotifications) { return; } if (!isMention && chat?.type === "group" && !prefs.groupNotifications) { return; } if (!isMention && chat?.type === "channel" && !prefs.channelNotifications) { return; } const titleBase = chat?.display_title || chat?.title || "New message"; const title = isMention ? `@ Mention in ${titleBase}` : titleBase; const preview = buildNotificationPreview(message, prefs.messagePreview); if (prefs.notificationSound) { playNotificationSound(); } void (async () => { const shownViaSw = await showNotificationViaServiceWorker({ chatId, messageId: message.id, title, body: preview.body, image: preview.image, }); if (shownViaSw) { return; } const notification = new Notification(title, { body: preview.body, icon: preview.image, tag: `chat-${chatId}`, }); notification.onclick = () => { window.focus(); const store = useChatStore.getState(); store.setActiveChatId(chatId); store.setFocusedMessage(chatId, message.id); notification.close(); }; })(); } function hasMentionForUser(text: string | null, username: string | null): boolean { if (!text || !username) { return false; } const normalizedUsername = username.toLowerCase(); const mentionRegex = /(^|[^A-Za-z0-9_])@([A-Za-z0-9_]{3,50})(?![A-Za-z0-9_])/g; let match: RegExpExecArray | null; while (true) { match = mentionRegex.exec(text); if (!match) { return false; } if ((match[2] ?? "").toLowerCase() === normalizedUsername) { return true; } } } function playNotificationSound(): void { if (typeof window === "undefined") { return; } const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioContextCtor) { return; } try { const context = new AudioContextCtor(); const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = "sine"; oscillator.frequency.setValueAtTime(880, context.currentTime); gain.gain.setValueAtTime(0.0001, context.currentTime); gain.gain.exponentialRampToValueAtTime(0.08, context.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16); oscillator.connect(gain); gain.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.17); oscillator.onended = () => { void context.close(); }; } catch { return; } }