修复会话开着但标签页在后台时消息被SDK自动标记已读、未读提示永远不触发的问题

用户实测报告:systest01打开着与systest02的会话,切到别的标签页后,systest02
发消息过来,systest01这边的标签页标题完全没反应。追查后发现根因比表面看到的
更深——这不是标题栏本身的bug,是更底层的已读状态被静默污染了。

读tinode-sdk源码确认:TopicMe自己的_routePres在处理服务端推给"me"话题的
`{pres what:"msg"}`通知时(这本来是让未在查看的会话也能收到未读更新的机制),
调用`cont._updateReceived(pres.seq, pres.act)`,只要`act`是假值或者等于自己,
就会无条件把`read`设成`seq`——也就是直接标记成已读。而一个只是保持"打开"状态
的会话(ConversationView挂载着、话题处于订阅状态),哪怕标签页在后台完全没人
看,一样会收到这条推送,被自动标记已读——跟标签页是否可见毫无关系。之前只
在ConversationView自己的显式noteRead()调用上做了可见性判断,根本拦不住这条
更底层、完全独立的SDK自身机制。

修复:在topics.ts里新增一套独立于SDK自身read/unread字段的"真正已读"记录
(markConfirmedRead/confirmedReadSeq),只在会话真正被看到时(打开且标签页
可见)才推进;me.onPres处理`{pres what:"msg"}`时,如果受影响的会话不是当前
正在被查看的那个,就用这份独立记录把SDK刚自动标记的已读状态纠正回去。
openConversation()打开会话时会拿当前seq给这份记录打个初始基准,但同样加了
可见性判断——直接刷新/深链到一个标签页本身就在后台的会话这种边界情况下,
不能假设"打开=看过"。activeConversationTopic的追踪从push.ts挪到了topics.ts
(两边都要用,且topics.ts不能反向依赖push.ts)。

实机验证(systest04/systest05全新账号对,因为systest01/systest02当时正被
用户自己在真实浏览器里同时测试,避免互相顶号干扰):会话保持打开、标签页
确认处于hidden状态时,对方发消息——未读数正确从0变成1,标题栏数字正确跟着
增加;随后模拟标签页重新变为可见——未读数正确清零、标题恢复正常,走完了
用户报告的完整场景闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 20:57:55 +08:00
co-authored by Claude Sonnet 5
parent 3d6ae089a1
commit 4bf4e75cac
3 changed files with 127 additions and 16 deletions
+45 -4
View File
@@ -26,11 +26,17 @@ import { availableActions, recallRemainingSeconds, type MessageAction } from '@/
import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { setActiveConversationTopic } from '@/lib/notifications/push';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, ensureTopicAttached, openConversation, subscribeTopicGone } from '@/lib/tinode/topics';
import {
closeConversation,
ensureTopicAttached,
markConfirmedRead,
openConversation,
setActiveConversationTopic,
subscribeTopicGone,
} from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify, notImplemented } from '@/stores/globalToast';
@@ -298,10 +304,45 @@ export function ConversationView() {
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
}, [messages, jumpTarget]);
// Gated on the tab actually being visible, not just this conversation being mounted -- a
// conversation left open in a BACKGROUNDED tab used to have every arriving message immediately
// noteRead()'d purely because the route was still mounted, with nobody actually looking at it. That
// made the chat-list unread count (and, since today, the tab-title scroll/favicon badge/desktop
// notification it drives -- see lib/notifications/) silently stay at zero for a conversation that
// was, from the user's real vantage point, sitting unread in a background tab the whole time (a
// real, reported case: systest01 has systest02's conversation open, tabs away, systest02 sends a
// message -- the tab title never picked it up). The effect below catches back up the moment the tab
// becomes visible again while still on this same conversation, so nothing is left permanently
// unread just because this guard skipped it while hidden.
//
// markConfirmedRead alongside noteRead -- this app's own independent "genuinely seen" record (see
// its own comment in topics.ts) that topics.ts's me.onPres correction relies on to undo the SDK's
// separate, unwanted auto-read-mark on a `{pres what:"msg"}` push for a topic that ISN'T actually
// being looked at. noteRead() alone only fixes what THIS component itself would have marked read;
// it doesn't tell that other mechanism what the user has genuinely seen.
useEffect(() => {
if (!topicId || messages.length === 0) return;
tinode.getTopic(topicId).noteRead(0);
}, [topicId, messages.length, tinode]);
if (document.visibilityState !== 'visible') return;
const topic = tinode.getTopic(topicId);
topic.noteRead(0);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
}, [topicId, messages, tinode]);
useEffect(() => {
if (!topicId) return undefined;
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
tinode.getTopic(topicId).noteRead(0);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId, tinode]);
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
+8 -12
View File
@@ -6,19 +6,14 @@
import type { StoredMessage } from 'tinode-sdk';
import { formatMessagePreview } from '@/lib/messages/preview';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange } from '@/lib/tinode/topics';
import { getActiveConversationTopic, getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange } from '@/lib/tinode/topics';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { useSessionStore } from '@/stores/session';
let activeTopic: string | null = null;
/** Called by ConversationView on mount/unmount -- the "am I already looking at this exact
* conversation" half of the suppression rule below; a message arriving in the topic you already
* have open (and the tab is actually visible, not just mounted in a background tab) shouldn't also
* pop a desktop notification for something already on screen. */
export function setActiveConversationTopic(topicId: string | null) {
activeTopic = topicId;
}
// "Am I already looking at this exact conversation" (topic open AND tab visible) is tracked in
// topics.ts, not here -- topics.ts's own me.onPres correction (see its comment) needs the same
// state, and topics.ts can't import it back from this module without a cycle. ConversationView calls
// topics.ts's setActiveConversationTopic directly; this module just reads it via the getter.
const DEBOUNCE_MS = 400;
/** How recent a topic's first-ever-observed message must be to count as "genuinely live" rather than
@@ -82,8 +77,9 @@ function shouldNotify(topicName: string, msg: StoredMessage): boolean {
// Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself.
if (msg.from && myUserId && msg.from === myUserId) return false;
// Already looking at this exact conversation, in the tab that's actually on screen right now --
// `activeTopic` alone isn't enough, since a backgrounded tab still keeps ConversationView mounted.
if (topicName === activeTopic && document.visibilityState === 'visible') return false;
// the active-topic check alone isn't enough, since a backgrounded tab still keeps ConversationView
// mounted.
if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return false;
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
if (contact?.private?.mute_notifications) return false;
return true;
+74
View File
@@ -22,6 +22,39 @@ const wiredTopics = new WeakSet<Topic>();
const previewPrefetched = new Set<string>();
const messageSnapshots = new Map<string, StoredMessage[]>();
let activeConversationTopic: string | null = null;
/** Set by ConversationView on mount/unmount -- "is the user genuinely looking at this exact
* conversation right now" (topic open AND tab visible), consumed both by lib/notifications/push.ts
* (suppress a desktop notification for something already on screen) and by the me.onPres correction
* below (don't fight the SDK's auto-read-mark for the ONE conversation actually being watched). Lives
* here rather than in push.ts because topics.ts itself now also needs to read it, and push.ts already
* depends on topics.ts -- the reverse would be a cycle. */
export function setActiveConversationTopic(topicId: string | null) {
activeConversationTopic = topicId;
}
export function getActiveConversationTopic(): string | null {
return activeConversationTopic;
}
const confirmedReadSeq = new Map<string, number>();
/**
* This app's OWN independent record of "the user has genuinely seen up to `seq` in this topic" --
* tracked separately from the SDK's own `read`/`unread` fields because those get silently advanced by
* a mechanism this app doesn't want for a conversation that's merely open in a backgrounded tab (see
* the me.onPres override in subscribeMeTopic below for the full story). Callers: openConversation
* seeds this the moment a conversation is freshly opened (opening one always means "I've seen
* everything up to here", regardless of tab visibility, since you can only open one by clicking on it
* while looking at the page); ConversationView additionally bumps it whenever it calls noteRead() while
* actually visible.
*/
export function markConfirmedRead(topicName: string, seq: number) {
const prev = confirmedReadSeq.get(topicName) || 0;
if (seq > prev) confirmedReadSeq.set(topicName, seq);
}
function notifyTopic(topicName: string) {
messageSnapshots.delete(topicName);
topicListeners.get(topicName)?.forEach((cb) => cb());
@@ -135,6 +168,37 @@ export function subscribeMeTopic(): Promise<unknown> {
// doesn't touch it. Without this, the chat list's unread badge/ordering goes stale after reading
// a conversation until some unrelated topic event happens to invalidate its cached snapshot.
me.onContactUpdate = () => notifyTopic(me.name);
// Real, live-confirmed bug (2026-09-16, reported directly): reading tinode-sdk's source, TopicMe's
// own `_routePres` handles a `{pres what:"msg"}` push -- the mechanism that updates a topic's unread
// count even when you're not actively viewing it -- by calling `cont._updateReceived(pres.seq,
// pres.act)`, which unconditionally sets `read = seq` (marks it AS READ) whenever `act` is falsy or
// refers to yourself. A conversation that's simply left OPEN (ConversationView mounted, topic
// actively subscribed) -- even sitting in a fully backgrounded tab nobody is looking at -- still gets
// this same presence push, and gets silently auto-marked read regardless of tab visibility: confirmed
// live, a message sent to a conversation left open in a backgrounded tab never bumped the unread
// count at all, defeating the tab-title/favicon/notification indicators this same session just built
// (ConversationView's own visibility-gated noteRead() call -- see its comment -- only prevents ITS
// OWN explicit read-marking; it has no way to intercept this separate, lower-level SDK mechanism).
// markConfirmedRead (see its own comment) is this app's independent record of when the user genuinely
// saw a topic. Right after the SDK's own (for this app's purposes, wrong) auto-mark runs, correct it
// back down to that record for any topic that isn't the one actually being looked at right now.
me.onPres = (pres: unknown) => {
notifyTopic(me.name);
const p = pres as { what?: string; src?: string } | undefined;
if (p?.what !== 'msg' || typeof p.src !== 'string') return;
const topicName = p.src;
const isBeingViewed = topicName === activeConversationTopic && document.visibilityState === 'visible';
if (isBeingViewed) return;
// Cast for the same reason getChatListSnapshot does -- read/unread/seq are real fields the SDK
// sets directly on the Topic instance at runtime, just not declared on this hand-maintained .d.ts.
const topic = getTinode().getTopic(topicName) as unknown as Contact;
const correctRead = confirmedReadSeq.get(topicName) || 0;
if ((topic.read || 0) > correctRead) {
topic.read = correctRead;
topic.unread = Math.max(0, (topic.seq || 0) - correctRead);
notifyTopic(topicName);
}
};
inFlightMeSubscribe = me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).finally(() => {
inFlightMeSubscribe = null;
});
@@ -218,6 +282,16 @@ export function openConversation(topicName: string): Promise<Topic> {
}
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
await topic.subscribe(query.build());
// Opening a conversation via a normal SPA click always means "I've seen everything up to here" --
// you can only click one while looking at the page. That assumption breaks for a direct reload or
// deep link straight to a conversation's URL in a tab that's sitting BACKGROUNDED the whole time
// (confirmed live: reloading tab-1 mid-test while it stayed the non-frontmost tab landed here with
// the tab genuinely hidden) -- gated on visibility so that case correctly leaves any
// already-unread history alone for the me.onPres correction above to keep reporting as unread,
// instead of this seeding call silently absolving it the instant the route mounts.
if (document.visibilityState === 'visible') {
markConfirmedRead(topicName, (topic as unknown as Contact).seq || 0);
}
return topic;
})();
inFlightSubscribe.set(topicName, promise);