From 4bf4e75cac0f041755750ec70f41c86a76dabed3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 20:57:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D=E5=BC=80?= =?UTF-8?q?=E7=9D=80=E4=BD=86=E6=A0=87=E7=AD=BE=E9=A1=B5=E5=9C=A8=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=97=B6=E6=B6=88=E6=81=AF=E8=A2=ABSDK=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=A0=87=E8=AE=B0=E5=B7=B2=E8=AF=BB=E3=80=81=E6=9C=AA?= =?UTF-8?q?=E8=AF=BB=E6=8F=90=E7=A4=BA=E6=B0=B8=E8=BF=9C=E4=B8=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测报告: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 --- .../features/messages/ConversationView.tsx | 49 +++++++++++- web/src/lib/notifications/push.ts | 20 ++--- web/src/lib/tinode/topics.ts | 74 +++++++++++++++++++ 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index a2ac5473..fbb2c319 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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 -- diff --git a/web/src/lib/notifications/push.ts b/web/src/lib/notifications/push.ts index f69f473a..22e8e194 100644 --- a/web/src/lib/notifications/push.ts +++ b/web/src/lib/notifications/push.ts @@ -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; diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 35cba77f..8e1cf39f 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -22,6 +22,39 @@ const wiredTopics = new WeakSet(); const previewPrefetched = new Set(); const messageSnapshots = new Map(); +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(); + +/** + * 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 { // 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 { } 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);