fix(messages): sync unread counters after reading

This commit is contained in:
lizerk 2026-08-06 13:40:02 +03:00
parent f2d24f70e3
commit 5214d6fa45
5 changed files with 95 additions and 4 deletions

View File

@ -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

View File

@ -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<number>();
const localReadWatermarks = new Map<number, number>();
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();
}

View File

@ -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);
});

View File

@ -0,0 +1,3 @@
import type { Chat } from "@/types/api";
export function applyChatReadWatermark(chat: Chat, watermark: number): Chat;

View File

@ -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),
};
}