diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 5d3bf140..e6f0e86f 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -35,6 +35,7 @@ import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useMarkRead } from "@/hooks/useMarkRead"; +import { markChatReadLocally } from "@/hooks/useMessagesChatList"; import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; @@ -182,11 +183,12 @@ function MessageDialogPageContent() { * ────────────────────────────────────────────────────── */ const markRead = useMarkRead({ chatId, - onReadAcknowledged: useCallback(() => { + onReadAcknowledged: useCallback((confirmedId: number) => { + markChatReadLocally(chatId, confirmedId); // We intentionally do NOT pull fresh chat/unread_count here. // The local newMessagesBelowCount resets only when the user // physically scrolls to the bottom (via syncScrollToBottomState). - }, []), + }, [chatId]), }); // Sync confirmed watermark from chat.last_read_message_id diff --git a/frontend/src/hooks/useMessagesChatList.ts b/frontend/src/hooks/useMessagesChatList.ts index 0741f69a..e4f8211d 100644 --- a/frontend/src/hooks/useMessagesChatList.ts +++ b/frontend/src/hooks/useMessagesChatList.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { apiClient } from "@/lib/api"; +import { applyChatReadWatermark } from "@/lib/messages/chatListState.js"; import wsManager from "@/lib/websocketManager"; import type { Chat } from "@/types/api"; @@ -16,6 +17,8 @@ type ChatListSnapshot = { type RealtimeChatEvent = { type?: string; chat_id?: number; + last_read_message_id?: number; + reader_user_id?: number; notification?: { verb?: string } | null; }; @@ -36,6 +39,7 @@ let wasWsConnected = false; let hasConnectedOnce = false; const listeners = new Set<() => void>(); const refreshingChatIds = new Set(); +const localReadWatermarks = new Map(); function emitChange() { listeners.forEach((listener) => listener()); @@ -46,6 +50,12 @@ function updateSnapshot(next: ChatListSnapshot) { emitChange(); } +function applyLocalReadWatermark(chat: Chat): Chat { + const watermark = localReadWatermarks.get(chat.id); + if (!watermark) return chat; + return applyChatReadWatermark(chat, watermark); +} + async function loadAllChats(showLoader: boolean) { if (loadPromise) return loadPromise; if (showLoader) { @@ -54,7 +64,7 @@ async function loadAllChats(showLoader: boolean) { loadPromise = (async () => { try { - const chats = await apiClient.getAllChats(); + const chats = (await apiClient.getAllChats()).map(applyLocalReadWatermark); updateSnapshot({ chats, loading: false, error: null, loaded: true }); } catch (error) { console.error("Ошибка загрузки чатов:", error); @@ -71,7 +81,7 @@ async function refreshChat(chatId: number) { if (!Number.isFinite(chatId) || chatId <= 0 || refreshingChatIds.has(chatId)) return; refreshingChatIds.add(chatId); try { - const chat = await apiClient.getChat(chatId); + const chat = applyLocalReadWatermark(await apiClient.getChat(chatId)); const existingIndex = snapshot.chats.findIndex((item) => item.id === chatId); const chats = [...snapshot.chats]; if (existingIndex === -1) { @@ -87,6 +97,33 @@ async function refreshChat(chatId: number) { } } +export function markChatReadLocally(chatId: number, lastReadMessageId?: number) { + if (!Number.isFinite(chatId) || chatId <= 0) return; + if (typeof lastReadMessageId === "number") { + localReadWatermarks.set( + chatId, + Math.max(localReadWatermarks.get(chatId) ?? 0, lastReadMessageId), + ); + } + let changed = false; + const chats = snapshot.chats.map((chat) => { + if (chat.id !== chatId) return chat; + const confirmedId = typeof lastReadMessageId === "number" + ? Math.max(chat.last_read_message_id ?? 0, lastReadMessageId) + : chat.last_read_message_id; + if ((chat.unread_count ?? 0) === 0 && confirmedId === chat.last_read_message_id) { + return chat; + } + changed = true; + return { + ...chat, + unread_count: 0, + last_read_message_id: confirmedId, + }; + }); + if (changed) updateSnapshot({ ...snapshot, chats }); +} + function startRealtime() { if (realtimeCleanup || !window.localStorage.getItem("access_token")) return; @@ -96,6 +133,14 @@ function startRealtime() { void refreshChat(data.chat_id); return; } + if ( + data.type === "marked_read" + && typeof data.chat_id === "number" + && data.reader_user_id === activeUserId + ) { + markChatReadLocally(data.chat_id, data.last_read_message_id); + return; + } if (data.type === "notification" && data.notification?.verb === "chat_added_to_chat") { void loadAllChats(false); } @@ -128,6 +173,7 @@ function retainChatList(userId: number) { snapshot = EMPTY_SNAPSHOT; loadPromise = null; refreshingChatIds.clear(); + localReadWatermarks.clear(); emitChange(); } diff --git a/frontend/src/lib/chat-list-state.test.js b/frontend/src/lib/chat-list-state.test.js new file mode 100644 index 00000000..14baa5b1 --- /dev/null +++ b/frontend/src/lib/chat-list-state.test.js @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { applyChatReadWatermark } from "./messages/chatListState.js"; + +test("confirmed chat reads clear a stale unread counter", () => { + const chat = { + id: 7, + unread_count: 3, + last_read_message_id: 40, + last_message: { id: 43 }, + }; + + assert.deepEqual(applyChatReadWatermark(chat, 43), { + ...chat, + unread_count: 0, + last_read_message_id: 43, + }); +}); + +test("messages newer than the local read watermark remain unread", () => { + const chat = { + id: 7, + unread_count: 1, + last_read_message_id: 43, + last_message: { id: 44 }, + }; + + assert.equal(applyChatReadWatermark(chat, 43), chat); +}); diff --git a/frontend/src/lib/messages/chatListState.d.ts b/frontend/src/lib/messages/chatListState.d.ts new file mode 100644 index 00000000..0aa3d1bd --- /dev/null +++ b/frontend/src/lib/messages/chatListState.d.ts @@ -0,0 +1,3 @@ +import type { Chat } from "@/types/api"; + +export function applyChatReadWatermark(chat: Chat, watermark: number): Chat; diff --git a/frontend/src/lib/messages/chatListState.js b/frontend/src/lib/messages/chatListState.js new file mode 100644 index 00000000..983b45b2 --- /dev/null +++ b/frontend/src/lib/messages/chatListState.js @@ -0,0 +1,10 @@ +export function applyChatReadWatermark(chat, watermark) { + if (!Number.isFinite(watermark) || watermark <= 0) return chat; + const lastMessageId = chat.last_message?.id; + if (typeof lastMessageId === "number" && lastMessageId > watermark) return chat; + return { + ...chat, + unread_count: 0, + last_read_message_id: Math.max(chat.last_read_message_id ?? 0, watermark), + }; +}