修复会话开着但标签页在后台时消息被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
+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);