Files
Messenger/web/src/hooks/useRealtime.ts
Codex ad2e0ede42
Some checks failed
CI / test (push) Failing after 2m20s
web: fix auth session races, ws token drift, and unread clear behavior
2026-03-09 02:17:14 +03:00

529 lines
21 KiB
TypeScript

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<string, unknown>;
timestamp: string;
}
export function useRealtime() {
const accessToken = useAuthStore((s) => s.accessToken);
const meId = useAuthStore((s) => s.me?.id ?? null);
const typingByChat = useRef<Record<number, Set<number>>>({});
const recordingVoiceByChat = useRef<Record<number, Set<number>>>({});
const recordingVideoByChat = useRef<Record<number, Set<number>>>({});
const typingTimersRef = useRef<Record<string, number>>({});
const recordingVoiceTimersRef = useRef<Record<string, number>>({});
const recordingVideoTimersRef = useRef<Record<string, number>>({});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const heartbeatIntervalRef = useRef<number | null>(null);
const watchdogIntervalRef = useRef<number | null>(null);
const lastPongAtRef = useRef<number>(Date.now());
const reconnectAttemptsRef = useRef(0);
const manualCloseRef = useRef(false);
const notificationPermissionRequestedRef = useRef(false);
const reloadChatsTimerRef = useRef<number | null>(null);
const reconcileIntervalRef = useRef<number | null>(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<string, number>, key: string) => {
const id = timers[key];
if (id !== undefined) {
window.clearTimeout(id);
delete timers[key];
}
};
const armActivityTimer = (
timers: Record<string, number>,
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<number>();
}
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<number>();
}
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<number>();
}
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<number>(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;
}
}