Files
im_webapp/web/src/lib/tinode/topics.ts
T
Your NameandClaude Sonnet 5 3fab8b08b9 被拉入新群时标题栏/桌面通知没有任何提示——扩展兜底同步机制覆盖这个场景
reconcileMeSubs()本来就能发现"此前完全不认识的话题"(今天早些时候为解决群聊
被动成员看不到新群加的兜底机制),但发现后只是原样信任服务端返回的read/unread
字段——服务端对一个刚加入的新成员,默认给的读游标经常是"视为已读"(read==seq),
如果这个群目前恰好一条消息都没有,unread就会是0,导致今天新加的标题栏滚动/
favicon角标(由同一个未读数驱动)完全没有反应,被拉群这件事本身悄无声息。

改成:对每一个新发现的话题,不管服务端给的read游标是什么,一律按"我自己完全
没确认过"处理(read清零,unread按当前seq计算);如果是群,额外通过
subscribeNewGroupDiscovered(新增的发布-订阅,push.ts订阅它去弹真正的桌面
通知"你被拉入了这个群聊",因为topics.ts不能反向依赖push.ts)触发一条独立的
桌面通知,不依赖未读数是否恰好大于0。

实机验证:systest04建一个只邀请systest05的全新群,systest05这边模拟切回
标签页触发兜底同步后,确认收到桌面通知(title/body/tag均正确)且标题栏
未读数正确增加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:55 +08:00

545 lines
31 KiB
TypeScript

// Per-topic pub-sub + subscription lifecycle, layered on top of connection.ts's connection-level
// pub-sub. Topic.onData/onInfo/etc. are single-slot callbacks exactly like Tinode.onConnect/etc.
// (see connection.ts's header comment) -- this module claims them once per topic and fans out to
// any number of subscribers, which is what hooks.ts's useSyncExternalStore-based hooks consume.
//
// Subscription lifecycle mirrors the old app's (verified against its tinode-web.jsx/messages-view.jsx):
// - The `me` topic is subscribed once per session, metadata only (no message bodies).
// - Every contact gets a one-shot "preview" subscription (withLaterData(1)) so the chat list can
// show a last-message preview; it is never explicitly left afterward.
// - Opening a conversation subscribes with a full history page. A topic already subscribed (from
// its preview prefetch) has its subscribe() short-circuited by the SDK if called again with new
// getParams -- so this always leaves first when already subscribed, then resubscribes.
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode, onReauthenticated } from './connection';
const topicListeners = new Map<string, Set<() => void>>();
const anyTopicListeners = new Set<() => void>();
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());
anyTopicListeners.forEach((cb) => cb());
}
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
let set = topicListeners.get(topicName);
if (!set) {
set = new Set();
topicListeners.set(topicName, set);
}
set.add(callback);
return () => set.delete(callback);
}
/** Fires on ANY topic's change -- used by the chat list, which derives its rows from every contact
* at once (`me.contacts()`) rather than tracking one topic in particular. */
export function subscribeAnyTopicChange(callback: () => void): () => void {
anyTopicListeners.add(callback);
return () => anyTopicListeners.delete(callback);
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call';
seq?: number;
from?: string;
event?: string;
payload?: Record<string, unknown>;
}
const infoListeners = new Set<(topicName: string, info: RawTopicInfo) => void>();
/** Raw `{info}` payloads (read/recv/kp/call), forwarded verbatim -- unlike `subscribeAnyTopicChange`/
* `subscribeTopicChanges`, which only signal "something changed, re-read the snapshot" for the
* chat-list/message-list hooks, calls.ts needs the actual `event`/`payload` fields off a `what:
* "call"` info to drive its own ringing/accept/hang-up state machine -- those aren't reconstructable
* from a message snapshot re-read the way a new chat message is. */
export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicInfo) => void): () => void {
infoListeners.add(callback);
return () => infoListeners.delete(callback);
}
const goneListeners = new Set<(topicName: string) => void>();
const newGroupListeners = new Set<(topicName: string) => void>();
/** Fires when reconcileMeSubs() (below) discovers a group topic this client has never seen before --
* the "you were just added to a group" case (Zentao/user-reported: "被拉入群时title的消息没有提示").
* Consumed by lib/notifications/push.ts to fire a desktop notification -- can't live there itself
* since push.ts already depends on topics.ts and the reverse would be a cycle, same reasoning as
* setActiveConversationTopic living here instead of in push.ts. */
export function subscribeNewGroupDiscovered(callback: (topicName: string) => void): () => void {
newGroupListeners.add(callback);
return () => newGroupListeners.delete(callback);
}
/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) --
* the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls
* `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this,
* a member who stays on a group's conversation screen while someone else dissolves it just keeps
* looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots
* for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */
export function subscribeTopicGone(callback: (topicName: string) => void): () => void {
goneListeners.add(callback);
return () => goneListeners.delete(callback);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
topic.onData = notify;
topic.onInfo = (info) => {
notify();
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
};
topic.onMetaDesc = notify;
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
// specifically, regardless of which topic's subscribe triggered it -- confirmed tinode.dev.js:
// 4654-4659) -- wiring it here on every topic is a no-op for anything else, same as onSubsUpdated
// right below, which already gets this same treatment. Without this, a brand-new P2P conversation
// reached by the other user's `usr...` id (no established topic yet) never tells the chat list a
// new contact exists the instant the server assigns its real `p2p...` name on first subscribe --
// the contact stays invisible in the sidebar, and ConversationView's own `contact` lookup (peer
// name, online status) keeps missing until some UNRELATED full resync happens to pick it up.
topic.onMetaSub = notify;
topic.onSubsUpdated = notify;
topic.onPres = notify;
topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify;
topic.onDeleteTopic = () => {
notify();
goneListeners.forEach((cb) => cb(topic.name));
};
wiredTopics.add(topic);
}
/** Cached per topic; only recomputed when notifyTopic() invalidates it, so useSyncExternalStore's
* getSnapshot returns a stable reference between real changes instead of a new array every render. */
export function getMessagesSnapshot(topicName: string): StoredMessage[] {
let cached = messageSnapshots.get(topicName);
if (!cached) {
const topic = getTinode().getTopic(topicName);
cached = [];
topic.messages((msg) => cached!.push(msg));
messageSnapshots.set(topicName, cached);
}
return cached;
}
/** Deduped the same way openConversation/connect are -- subscribing the same `me` topic twice
* concurrently is the same non-idempotent-WS-call shape as those (currently reachable from
* RequireAuth.tsx/LoginPage.tsx, which don't overlap today, but this is cheap insurance against a
* future call site that does, e.g. a manual "reload contacts" retry button). */
let inFlightMeSubscribe: Promise<unknown> | null = null;
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
export function subscribeMeTopic(): Promise<unknown> {
if (inFlightMeSubscribe) return inFlightMeSubscribe;
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
// TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks
// 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;
});
return inFlightMeSubscribe;
}
/** Tracks prefetchPreview's own in-flight subscribe per topic, so openConversation (below) can wait
* for it to settle first instead of firing a second overlapping `{sub}` packet at the same topic --
* this is a real path, not just theoretical: ChatListColumn prefetches every visible contact's
* preview on render, and a deep link or fast click can open one of those same topics before its
* preview subscribe has resolved. */
const inFlightPreview = new Map<string, Promise<unknown>>();
/**
* One-shot per topic: subscribes just enough to learn its latest message for the chat-list preview.
* Deliberately never left afterward (matches the old app) -- simpler than tracking teardown for a
* subscription that's cheap to leave attached and likely to be reopened soon anyway.
*/
export function prefetchPreview(topicName: string): void {
if (previewPrefetched.has(topicName)) return;
previewPrefetched.add(topicName);
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
const promise = topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {
previewPrefetched.delete(topicName);
});
inFlightPreview.set(topicName, promise);
promise.finally(() => {
if (inFlightPreview.get(topicName) === promise) inFlightPreview.delete(topicName);
});
}
/**
* The SDK has no per-topic in-flight tracking of its own: `Topic.subscribe()` only short-circuits
* once `_attached` is already true, so two overlapping calls (React 19 StrictMode's mount->cleanup->
* remount runs every effect twice in dev, so openConversation() always overlaps with itself once per
* open; ChatListColumn's prefetchPreview and this both racing for the same just-deep-linked topic is
* the same shape in prod) would otherwise send two `{sub}` packets, and a `leave()` that lands before
* the first `subscribe()` resolves just rejects ("Cannot leave inactive topic") and no-ops instead of
* detaching. Tracked here so every caller for the same topic name shares one in-flight promise.
*/
const inFlightSubscribe = new Map<string, Promise<Topic>>();
/**
* Bumped by every openConversation() call, checked by closeConversation() after it's done waiting --
* without this, StrictMode's mount->cleanup->remount still breaks a freshly (re)opened conversation:
* cleanup-1 awaits the SAME shared inFlightSubscribe promise mount-2 is also relying on (they're
* deduped to one promise, per the comment above), and once it resolves, cleanup-1's queued
* continuation calls leave() unconditionally -- detaching the subscription mount-2 just established
* a moment earlier, with no error surfaced anywhere (confirmed by reading tinode.dev.js: `_attached`
* only flips true on a successful subscribe, and leave(false) while `_attached` is true really does
* send `{leave}` and reset it). The topic silently stops receiving live messages/receipts. Capturing
* the generation synchronously at closeConversation's call time and re-checking it after all awaits
* settle detects "a newer open has since claimed this topic" and skips the stale leave().
*/
const topicGeneration = new Map<string, number>();
/**
* Opens a topic for full conversation view: fetches a page of history and wires live updates.
* Uses withData(undefined, undefined, limit), NOT withLaterData(limit) -- if prefetchPreview already
* nudged the topic's local _maxSeq up to just that one preview message, withLaterData would ask for
* "newer than that" and the server would correctly report back empty.
*
* Known gap: for a brand-new P2P topic the peer has never messaged, the old app defers subscribing
* at all until the user's first send (avoiding server-side subscription state for chats opened but
* never used) -- not yet replicated here since the new-conversation picker flow doesn't exist yet.
*/
export function openConversation(topicName: string): Promise<Topic> {
topicGeneration.set(topicName, (topicGeneration.get(topicName) || 0) + 1);
const existing = inFlightSubscribe.get(topicName);
if (existing) return existing;
const promise = (async () => {
// Let any in-flight chat-list preview subscribe for this same topic settle first, rather than
// firing a second overlapping `{sub}` packet at it (see inFlightPreview above).
const previewPending = inFlightPreview.get(topicName);
if (previewPending) await previewPending.catch(() => {});
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
if (topic.isSubscribed()) {
await topic.leave(false);
}
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);
promise.finally(() => {
if (inFlightSubscribe.get(topicName) === promise) inFlightSubscribe.delete(topicName);
});
return promise;
}
/** Soft-leave (detach, not unsubscribe) -- cached messages stay in the SDK's local buffer. Waits out
* any in-flight openConversation() for this same topic first, so a cleanup that fires before the
* subscribe it's undoing has even resolved doesn't just silently no-op (see inFlightSubscribe above)
* -- then, per topicGeneration's comment above, skips the actual leave() if a NEWER openConversation
* call has since claimed this topic while this one was waiting. */
export async function closeConversation(topicName: string): Promise<void> {
const myGeneration = topicGeneration.get(topicName) || 0;
const pending = inFlightSubscribe.get(topicName);
if (pending) await pending.catch(() => {});
if (topicGeneration.get(topicName) !== myGeneration) return;
await getTinode().getTopic(topicName).leave(false).catch(() => {});
}
/**
* Makes sure a topic can be published to right now, without disturbing whichever conversation is
* actually open. `Topic.publishMessage()` rejects outright unless `_attached` is true, and that's
* only reliably the case for the ONE topic openConversation/closeConversation currently has open --
* any OTHER topic you've ever actually opened and then navigated away from goes back to detached and
* STAYS that way (closeConversation's leave() doesn't restore prefetchPreview's original one-shot
* subscription, and prefetchPreview itself no-ops on a topic it's already touched once, attached or
* not). That covers forwarding a message to anyone other than the person/group you're currently
* chatting with -- i.e. most real forwards -- so this isn't a rare edge case to special-case around,
* it's the common one. Reuses `inFlightSubscribe` so a concurrent openConversation() for the same
* topic is awaited/shared rather than racing it with a second `{sub}` packet (see openConversation's
* own comment above for why that matters). Deliberately left attached afterward rather than re-left --
* same "cheap to leave attached, likely reopened soon anyway" call prefetchPreview already makes.
*/
export function ensureTopicAttached(topicName: string): Promise<Topic> {
const topic = getTinode().getTopic(topicName);
const pending = inFlightSubscribe.get(topicName);
if (pending) return pending;
if (topic.isSubscribed()) return Promise.resolve(topic);
wireTopicCallbacks(topic);
const promise = topic.subscribe().then(() => topic);
inFlightSubscribe.set(topicName, promise);
promise.finally(() => {
if (inFlightSubscribe.get(topicName) === promise) inFlightSubscribe.delete(topicName);
});
return promise;
}
let chatListSnapshot: Contact[] | null = null;
/**
* Real, addressable chat-list topic types this backend actually issues -- deliberately NOT using
* `Topic#isCommType()`/`isP2PType()` (confirmed live by reading tinode-sdk's own `Topic.topicType()`
* table: it maps prefix `'usr'` to P2P, with NO entry for `'p2p'` at all). That table is right for a
* peer ALIAS (`usr<peerId>`, used before a P2P topic has ever been subscribed), but this backend's
* REAL, server-assigned P2P topic name is `p2p<hash>` (confirmed via raw `{meta topic:"me"}` capture:
* every subscription the server reports uses this form, never the alias) -- so `isCommType()` returns
* false for every P2P topic once its real name is known, silently filtering ALL of them out of
* `MeTopic.contacts()`. This has nothing to do with account age or how a conversation was opened --
* verified by direct fresh-login inspection that even a heavily-used account with many real, working
* P2P friendships had ZERO P2P entries in `contacts()`; every P2P row the chat list has ever shown
* this session was actually the special-cased `slf` self-chat entry.
*/
function isChatListTopicName(name: string): boolean {
return name.startsWith('p2p') || name.startsWith('grp') || name === 'slf';
}
/**
* Cached the same way getMessagesSnapshot is, but invalidated by ANY topic's change (a new message
* on a contact that isn't currently open still needs to move that row and refresh its preview).
* Triggers each contact's one-shot preview prefetch as a side effect of being read -- cheap and
* idempotent (prefetchPreview no-ops after the first call per topic), so it's fine to do this on
* every rebuild rather than needing a separate "wire up previews" pass.
*
* Uses `Tinode#mapTopics` (every locally-cached topic, unfiltered) + `isChatListTopicName` instead of
* `MeTopic#contacts()` -- see that function's own comment for why `contacts()`'s built-in filter can't
* be trusted here. `mapTopics` still only sees topics the SDK has locally cached, which `me`'s own
* subscribe (`subscribeMeTopic`'s `withLaterSub()`) and any direct `openConversation()` both populate.
*/
export function getChatListSnapshot(): Contact[] {
if (chatListSnapshot) return chatListSnapshot;
const byTopic = new Map<string, Contact>();
getTinode().mapTopics((topic) => {
if (!topic.name || !isChatListTopicName(topic.name)) return;
prefetchPreview(topic.name);
// `.topic` (what every consumer of this list actually reads) normally arrives via
// `_processMetaSubs` merging the server's own `sub.topic` field onto this same cached instance --
// but a brand-new P2P topic reached through `openConversation()` is cached here the moment its
// OWN subscribe resolves, potentially before `me`'s subscription list has ever reported it. Set
// explicitly rather than assuming that merge already happened; kept as the same live instance
// (not a clone) so prototype methods like `latestMessage()` survive.
const contact = topic as unknown as Contact;
contact.topic = topic.name;
byTopic.set(topic.name, contact);
});
const rows = [...byTopic.values()];
chatListSnapshot = rows;
return rows;
}
function invalidateChatListSnapshot() {
chatListSnapshot = null;
}
anyTopicListeners.add(invalidateChatListSnapshot);
/**
* Re-fires the `me` topic subscribe and every contact's preview subscribe once a WS reconnect has
* successfully re-authenticated (connection.ts's onReauthenticated). Bypasses subscribeMeTopic's/
* prefetchPreview's own "already done" guards on purpose -- those guards exist to stop a second
* *concurrent* call from firing a redundant `{sub}` while one is already in flight, not to reflect
* whether the topic is still genuinely attached, and after any disconnect it never is (the SDK
* resets `_attached` on every cached topic, confirmed by reading tinode.dev.js's #disconnected()).
* `Topic.subscribe()` itself only short-circuits when `_attached` is still true, so calling it again
* here for an already-detached topic genuinely resends `{sub}`, not a silent no-op.
*
* Deliberately scoped to topics this module already knows about (the `me` topic + everything
* prefetchPreview has ever touched) rather than tracking "the one conversation currently open" as
* separate state -- in practice the chat list and an open conversation are mounted together in this
* app's layout, so the open topic's contact row has already gone through prefetchPreview by the time
* anyone could be looking at it. A conversation reached by a deep link that never rendered the chat
* list at all is the one case this doesn't cover; also out of scope: backfilling any messages that
* were sent by the peer during the drop itself -- this only resumes *live* updates going forward,
* it doesn't gap-fill what was missed while disconnected.
*/
onReauthenticated(() => {
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
me.onContactUpdate = () => notifyTopic(me.name);
me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).catch(() => {});
for (const topicName of previewPrefetched) {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
}
});
const ME_RECONCILE_INTERVAL_MS = 30_000;
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
/**
* Best-effort mitigation for a real backend gap that no amount of frontend-only code can fully close:
* confirmed by reading im_be's server/im_group_events.go, EVERY group lifecycle event (created, member
* added/removed, dissolved, owner transferred, ...) goes through publishIMGroupRealtimeEvent, which
* only reaches sessions ALREADY subscribed to that specific group topic. Unlike P2P -- which has a
* dedicated force-delivery path straight to a peer's session regardless of subscription state
* (deliverIMConversationSystemMessageToP2PSessions) -- groups have no equivalent, so a member who has
* never opened a given group gets zero live signal when added to it: no chat-list row, no unread badge,
* nothing, indefinitely, until something re-syncs. Confirmed live (2026-09-16): a brand-new group with a
* genuinely passive, fully-connected, already-on-/messages member produced no change at all, even
* several seconds after a real message was sent into it.
*
* There is nothing to subscribe to or react to for a topic this client doesn't know exists yet, so the
* only thing a client can do is periodically ask "what's new" -- reusing the exact same incremental
* `withLaterSub()` semantics subscribeMeTopic()/onReauthenticated() above already rely on, just issued
* via `getMeta()` instead of `subscribe()`. `Topic.subscribe()` short-circuits once `_attached` is
* already true (the normal, undisturbed state this is guarding, unlike the reconnect case above), but
* `getMeta()` sends the `{get}` regardless of attach state -- the same distinction ConversationView's
* own P2P desc/presence fix relies on.
*/
function reconcileMeSubs() {
const me = getTinode().getMeTopic();
if (!me.isSubscribed()) return;
// Snapshot every currently-known topic's read-state fields before the fetch -- see the .then()
// below for why the response can't be trusted at face value for topics already tracked (only for
// genuinely new ones, which is this mitigation's entire reason to exist).
const before = new Map<string, { seq: number; read: number; recv: number; unread: number }>();
for (const c of getChatListSnapshot()) {
if (c.topic) before.set(c.topic, { seq: c.seq || 0, read: c.read || 0, recv: c.recv || 0, unread: c.unread || 0 });
}
me.getMeta(me.startMetaQuery().withLaterSub().build())
.then(() => {
// Real, live-confirmed bug (2026-09-16, reported directly): tinode-sdk's own merge utility
// (mergeObj in utils.js) has a defensive "keep the later value" rule for Date fields but NONE
// for plain numbers -- seq/read/recv/unread just get blindly overwritten by whatever this
// fetch's response says, confirmed by reading the source. If the server's view of a sub's read
// cursor is even momentarily behind a `{note read}` the client already sent (e.g. this timer or
// a focus/visibility trigger firing shortly after the user opened and read a conversation), this
// silently un-reads it: the badge reappears with no new message behind it, and re-focusing the
// window doesn't clear it either since nothing about it actually changed to re-trigger a fresh
// read-mark -- confirmed reproducing exactly like this live, not a hypothetical race. Since plain
// numbers can't defend themselves the way Dates do, restore anything that regressed for topics
// already known BEFORE this fetch started, rather than trust the fetch there -- this mitigation
// only needs to win for topics that were unknown before it (the actual gap it exists to close),
// not overwrite ones a user has already read in the meantime.
for (const c of getChatListSnapshot()) {
if (!c.topic) continue;
const prev = before.get(c.topic);
if (!prev) {
// Brand-new topic to this session -- exactly what this mitigation is for. A fresh member's
// sub record can legitimately come back "already caught up" (read === seq) from the
// server's own default, which would otherwise leave being newly added to a group producing
// NO unread signal at all whenever it happens to have zero messages so far -- real,
// reported UX gap ("被拉入群时title的消息没有提示"): the tab-title/favicon-badge (see lib/
// notifications/tabIndicator.ts) are driven by this same unread count, so nothing tells the
// user anything happened. Force it to read as genuinely unread from this client's own
// perspective regardless of what the server defaulted it to.
c.read = 0;
c.unread = c.seq || 0;
notifyTopic(c.topic);
if (c.topic.startsWith('grp')) newGroupListeners.forEach((cb) => cb(c.topic!));
continue;
}
const seq = c.seq || 0;
const unread = c.unread || 0;
if (seq <= prev.seq && unread > prev.unread) {
c.read = prev.read;
c.recv = prev.recv;
c.unread = prev.unread;
notifyTopic(c.topic);
}
}
})
.catch(() => {});
}
/**
* Starts the periodic timer plus tab-focus/visibility triggers (the common "switched away and came
* back" case gets an immediate catch-up instead of waiting for the next tick). Call once per
* authenticated session (RequireAuth.tsx) and invoke the returned function on logout/unmount -- safe
* to start/stop repeatedly (React 19 StrictMode's mount->cleanup->remount included), unlike
* subscribeMeTopic() itself, which is guarded separately against being re-fired.
*/
export function startMeReconciliation(): () => void {
meReconcileTimer = setInterval(reconcileMeSubs, ME_RECONCILE_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') reconcileMeSubs();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', reconcileMeSubs);
return () => {
if (meReconcileTimer) clearInterval(meReconcileTimer);
meReconcileTimer = null;
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', reconcileMeSubs);
};
}