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