fix(messages): sync unread counters after reading
This commit is contained in:
parent
f2d24f70e3
commit
5214d6fa45
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
30
frontend/src/lib/chat-list-state.test.js
Normal file
30
frontend/src/lib/chat-list-state.test.js
Normal 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);
|
||||
});
|
||||
3
frontend/src/lib/messages/chatListState.d.ts
vendored
Normal file
3
frontend/src/lib/messages/chatListState.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import type { Chat } from "@/types/api";
|
||||
|
||||
export function applyChatReadWatermark(chat: Chat, watermark: number): Chat;
|
||||
10
frontend/src/lib/messages/chatListState.js
Normal file
10
frontend/src/lib/messages/chatListState.js
Normal 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),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user