修复会话开着但标签页在后台时消息被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:
co-authored by
Claude Sonnet 5
parent
3d6ae089a1
commit
4bf4e75cac
@@ -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 --
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user