840 lines
51 KiB
TypeScript
840 lines
51 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 { getGroup } from '@/lib/api/groups';
|
|
import { MESSAGES_PAGE } from '@/lib/config';
|
|
|
|
import { getTinode, onReauthenticated, onTinodeReset } 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;
|
|
}
|
|
|
|
/**
|
|
* The other party's real `usr...` id for a P2P conversation -- NOT the same as `topic.name` once a
|
|
* conversation has any real history. Real, live-confirmed bug this fixes: a P2P topic addressed by
|
|
* the peer's own `usr...` id (the only address that exists before the very first subscribe -- e.g.
|
|
* straight from a friend's profile) gets renamed by the SDK to a real, opaque `p2p...` name the
|
|
* instant `subscribe()` succeeds (see openConversation's own doc comment on this same rename). Every
|
|
* P2P conversation with actual history is addressed by this `p2p...` form from then on -- but reading
|
|
* tinode.dev.js confirms `Topic.topicType()`'s prefix table has no `p2p` entry at all (only `usr` maps
|
|
* to the P2P type), so `topic.isP2PType()`/`topic.p2pPeerDesc()` silently return false/undefined for
|
|
* every one of these topics, and code that assumed "the P2P topic name IS the peer's user id" (true
|
|
* only for the brief pre-first-subscribe window) breaks the moment that assumption stops holding --
|
|
* confirmed live: `GET /users/{topicName}/profile` 404s, `friends.find(f => f.user_id === topicName)`
|
|
* never matches, block/remark/avatar all silently target a user id that doesn't exist.
|
|
*
|
|
* Returns `undefined` (never `topic.name` as a guess) when the subscriber list isn't populated yet --
|
|
* live-confirmed this matters: on the render right after navigating straight to a `p2p...`-named
|
|
* conversation, `subscribers()` can still be empty for the one or two renders before the subscribe
|
|
* response fills it in, and returning `topic.name` as a placeholder there just fires the exact same
|
|
* doomed `GET /users/{p2p...}/profile` request this function exists to prevent, a render later. Every
|
|
* caller already guards its query with `enabled: !!peerUserId`, so `undefined` correctly makes it wait
|
|
* instead of firing once with a value it knows is wrong.
|
|
*/
|
|
export function getP2PPeerUserId(topic: Topic, myUserId: string | null | undefined): string | undefined {
|
|
if (topic.name.startsWith('usr')) return topic.name;
|
|
let peer: string | undefined;
|
|
topic.subscribers((sub) => {
|
|
if (!peer && sub.user !== myUserId) peer = sub.user;
|
|
});
|
|
return peer;
|
|
}
|
|
|
|
const confirmedReadSeq = new Map<string, number>();
|
|
// Tinode reserves this sequence and higher values for optimistic local messages.
|
|
const LOCAL_SEQID = 0xfffffff;
|
|
|
|
export function getConfirmedRead(topicName: string): number {
|
|
const seq = confirmedReadSeq.get(topicName) || 0;
|
|
return seq > 0 && seq < LOCAL_SEQID ? seq : 0;
|
|
}
|
|
|
|
/** Repair an SDK contact polluted by a local sequence before it reaches the list or sync snapshot. */
|
|
export function repairContactReadState(contact: Pick<Contact, 'seq' | 'read' | 'recv' | 'unread'>): void {
|
|
const seq = contact.seq || 0;
|
|
const read = contact.read || 0;
|
|
const recv = contact.recv || 0;
|
|
if (read < LOCAL_SEQID && recv < LOCAL_SEQID && read <= seq && recv <= seq && (contact.unread || 0) >= 0) return;
|
|
contact.read = Math.min(Math.max(0, read), seq);
|
|
contact.recv = Math.min(Math.max(contact.read, recv), seq);
|
|
contact.unread = Math.max(0, seq - contact.read);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (!Number.isInteger(seq) || seq <= 0 || seq >= LOCAL_SEQID) return;
|
|
const prev = getConfirmedRead(topicName);
|
|
if (seq > prev) confirmedReadSeq.set(topicName, seq);
|
|
}
|
|
|
|
// Live-confirmed root cause of a real reported bug ("聊天消息框滚动到顶部或底部时UI会抖动闪烁"):
|
|
// `topic.onData = notify` (wireTopicCallbacks below) fires once per INDIVIDUAL message, not once per
|
|
// batch -- confirmed by instrumenting a real `loadOlder()` page fetch (24 older messages): the DOM
|
|
// mutated in ~14 separate small steps over ~400ms, each one growing `scrollHeight` a little and each
|
|
// one racing the OTHER open effect that reads it (loadOlder's own scroll-compensation, and
|
|
// ConversationView's separate "autoscroll to bottom" effect) -- neither gets a stable, final
|
|
// `scrollHeight` to work with until the whole page has actually finished arriving, so each of the ~14
|
|
// intermediate re-renders nudges `scrollTop` again, which is what actually reads as "shaking" rather
|
|
// than one clean jump. This wasn't reproducible by reasoning about a single `getMessagesPage()` call
|
|
// in isolation -- it only showed up instrumenting `scrollTop` across a real fetch.
|
|
// Fix: coalesce any notifications that land within the same animation frame into one flush, so a
|
|
// burst of N `onData` calls (initial page load, `loadOlder()`, or a fast run of incoming messages)
|
|
// triggers at most one re-render per frame instead of N -- imperceptible as a delay, but it means
|
|
// `scrollHeight` is only ever read by a re-render once it reflects everything that arrived so far in
|
|
// that frame, not some in-between partial state. `messageSnapshots.delete` stays synchronous (it's
|
|
// pure cache invalidation, not a re-render) so a snapshot read between two notifications in the same
|
|
// frame still sees the latest data, only the LISTENER callbacks (which is what schedules React's own
|
|
// re-render) are what get batched.
|
|
const pendingTopicNotifications = new Set<string>();
|
|
let pendingAnyNotification = false;
|
|
let notifyFrameScheduled = false;
|
|
|
|
function flushTopicNotifications() {
|
|
notifyFrameScheduled = false;
|
|
const topics = [...pendingTopicNotifications];
|
|
pendingTopicNotifications.clear();
|
|
const flushAny = pendingAnyNotification;
|
|
pendingAnyNotification = false;
|
|
for (const topicName of topics) {
|
|
topicListeners.get(topicName)?.forEach((cb) => cb());
|
|
}
|
|
if (flushAny) {
|
|
anyTopicListeners.forEach((cb) => cb());
|
|
}
|
|
}
|
|
|
|
function notifyTopic(topicName: string) {
|
|
messageSnapshots.delete(topicName);
|
|
pendingTopicNotifications.add(topicName);
|
|
pendingAnyNotification = true;
|
|
if (!notifyFrameScheduled) {
|
|
notifyFrameScheduled = true;
|
|
// Live-confirmed real bug (surfaced while testing an unrelated group-rename fix, in a webview whose
|
|
// frame compositing was paused, i.e. genuinely not painting -- not just an ordinary backgrounded
|
|
// browser tab): a `requestAnimationFrame` scheduled while the page isn't compositing frames can go
|
|
// uncalled indefinitely rather than merely deferred-until-visible. Since `notifyFrameScheduled` only
|
|
// resets to false inside the callback, one dropped frame permanently wedges this guard, silently
|
|
// freezing EVERY future topic-list update (not just the group name) until a full page reload -- data
|
|
// kept arriving and updating correctly underneath (confirmed live: `me.contacts()` had the right
|
|
// data the whole time), only the UI never found out. `document.hidden` costs nothing to check and
|
|
// `setTimeout` is never silently dropped the way this rAF was, so route through it whenever the page
|
|
// isn't visible; keep `requestAnimationFrame` for the visible case since that's what actually gives
|
|
// the intended one-flush-per-paint batching this function exists for.
|
|
if (typeof document !== 'undefined' && document.hidden) {
|
|
setTimeout(flushTopicNotifications, 0);
|
|
} else {
|
|
requestAnimationFrame(flushTopicNotifications);
|
|
}
|
|
}
|
|
}
|
|
|
|
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' | 'im' | 'group';
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* The SDK only routes an incoming {info} packet into Topic._routeInfo (and from there to
|
|
* topic.onInfo -> the infoListeners fanout above) when the packet's topic name is already in the
|
|
* client's topic cache -- tinode.dev.js's dispatcher does `#cacheGet('topic', pkt.info.topic)` and
|
|
* silently drops the frame on a miss. Friend-request signals (Zentao #3) arrive with `topic` set to
|
|
* the RECIPIENT'S OWN user id (publishIMRelationEvent -> sessionStore.DeliverToUser, i.e. addressed
|
|
* to the user's `me` topic), but the me topic is cached under the literal "me" -- so that lookup
|
|
* misses every time and the frame never reached any info listener. Live-confirmed 2026-09-18: the
|
|
* SDK's own logger showed the raw `friend.request.rejected` frame arriving on the socket while
|
|
* AppShell's subscribeTopicInfo callback never fired. The connection-level onInfoMessage hook sees
|
|
* EVERY info packet regardless of cache state, so forward ONLY the frames the topic-level path will
|
|
* not deliver (isTopicCached false) to the same infoListeners: cached topics keep flowing through
|
|
* topic.onInfo exactly once (no duplicates), uncached ones stop being silently lost. Both members
|
|
* are real SDK runtime members absent from the hand-maintained .d.ts (same cast rationale as the
|
|
* read/unread reads in getChatListSnapshot). Installed from subscribeMeTopic -- the one point every
|
|
* login flows through -- and re-installed there after resetTinode() rebuilds the instance.
|
|
*/
|
|
function installUncachedInfoFallback(t: unknown) {
|
|
const sdk = t as {
|
|
onInfoMessage?: ((info: RawTopicInfo & { topic: string }) => void) | null;
|
|
isTopicCached?: (topicName: string) => boolean;
|
|
};
|
|
sdk.onInfoMessage = (info) => {
|
|
if (info && sdk.isTopicCached && !sdk.isTopicCached(info.topic)) {
|
|
infoListeners.forEach((cb) => cb(info.topic, info));
|
|
}
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Topics confirmed gone that `getChatListSnapshot` must never show again THIS session, no matter what
|
|
* re-creates a cache entry for the name afterward -- live-confirmed real race: `openConversation`'s
|
|
* own cleanup (`closeConversation`, fired by ConversationView's unmount effect as the redirect below
|
|
* unmounts it) calls `getTinode().getTopic(topicName)` again for its `.leave(false)` -- unconditionally,
|
|
* with no way to know this specific name should stay forgotten -- which by itself re-creates a fresh,
|
|
* empty stub the instant `cacheRemTopic` below has removed the old one, silently undoing the cleanup
|
|
* and leaving the exact same broken sidebar row behind. Excluding by name in the chat-list filter
|
|
* itself sidesteps that ordering race entirely rather than trying to make cleanup run strictly after
|
|
* every other thing that might touch this topic's cache entry.
|
|
*/
|
|
const goneTopicNames = new Set<string>();
|
|
|
|
/**
|
|
* Shared by every "this topic is confirmed gone" path below -- native `onDeleteTopic` (a real `{pres
|
|
* what:"gone"}`, which never actually happens for THIS backend's own group dissolve -- see the
|
|
* `group.dissolved` handling in wireTopicCallbacks' `onInfo` for why) and that custom path itself both
|
|
* call this, so cache cleanup can't accidentally happen on only one of them. Removing the topic from
|
|
* the SDK's cache matters even here (not just for the 404-before-ever-subscribing case forgetTopic
|
|
* covers) -- confirmed live: a passive member watching a group when someone else dissolves it now
|
|
* correctly gets bounced to /messages the instant it happens, but the group still lingered in their
|
|
* chat list afterward (raw topic id, "1970/1/1") until this was added, same broken-stub symptom, just
|
|
* reached via the live path instead of the refresh-into-already-gone one.
|
|
*
|
|
* Skips everything (including `goneListeners`) once `goneTopicNames` already has this name -- real,
|
|
* user-reported bug: leaving a group yourself (GroupDetailColumn's 退出群聊/解散群聊, which calls
|
|
* `forgetTopic` synchronously the instant its own REST call succeeds, see below) still leaves the
|
|
* server free to ALSO deliver a native/`group.dissolved` gone-signal for that same topic a moment
|
|
* later, since from the server's point of view your own subscription really did just end too. Left
|
|
* unguarded, that late-arriving signal would re-run `goneListeners` a second time for a topic already
|
|
* handled -- showing ConversationView's "该群聊已解散" toast (wrong wording for a leave you chose
|
|
* yourself, not a dissolve) on top of whatever that action's own success handler already did.
|
|
*/
|
|
function markTopicGoneAndNotify(topicName: string) {
|
|
if (goneTopicNames.has(topicName)) return;
|
|
goneTopicNames.add(topicName);
|
|
getTinode().cacheRemTopic(topicName);
|
|
notifyTopic(topicName);
|
|
goneListeners.forEach((cb) => cb(topicName));
|
|
}
|
|
|
|
/**
|
|
* For any topic THIS client already knows is gone without needing to be told by a server push --
|
|
* `openConversation()` rejecting with a 404 (see ConversationView's `isTopicGone` handling) because it
|
|
* was deep-linked/refreshed into after already being dissolved, or GroupDetailColumn's own 退出群聊/
|
|
* 解散群聊 success handlers calling this immediately rather than waiting on the async gone-signal that
|
|
* usually follows. Doesn't go through markTopicGoneAndNotify's `goneListeners` trigger -- every caller
|
|
* already has its own richer, action-specific way to notify+navigate (a 404 catch block with the real
|
|
* `err`; a mutation's own `onSuccess`), so firing goneListeners too would just re-run that a second
|
|
* time, and for the leave/disband callers specifically, with the WRONG generic "该群聊已解散" wording.
|
|
* Adding to `goneTopicNames` here first is exactly what makes markTopicGoneAndNotify's own guard above
|
|
* effective against whatever late server push for this same topic follows.
|
|
*/
|
|
export function forgetTopic(topicName: string): void {
|
|
goneTopicNames.add(topicName);
|
|
getTinode().cacheRemTopic(topicName);
|
|
notifyTopic(topicName);
|
|
}
|
|
|
|
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));
|
|
// Real, live-confirmed gap (user-reported: "被解散当时没有" [redirect] -- only a later manual
|
|
// refresh ever caught it): this backend's own group-dissolve notification (server/
|
|
// im_group_events.go's publishIMGroupRealtimeEvent) travels as a custom `{info what:"group"
|
|
// event:"group.dissolved"}` packet, NOT the native Tinode `{pres what:"gone"}` the SDK's own
|
|
// `_gone()`/`onDeleteTopic` chain below is built around. Confirmed by reading tinode.dev.js's
|
|
// `_routeInfo`: `what:"group"` matches none of its explicit switch cases (recv/read/kp/kpa/kpv/
|
|
// call), so nothing native reacts to it -- `onInfo(info)` still fires right after regardless, but
|
|
// until now nothing here recognized this specific shape either. A member who was ACTIVELY,
|
|
// PASSIVELY watching a group (never the one who dissolved it themselves -- that flow already
|
|
// navigates away on its own mutation success, which is why testing only as the dissolver always
|
|
// looked correct) got no live signal at all: only a later refresh, hitting openConversation's 404
|
|
// path (see ConversationView's isTopicGone), ever discovered it was gone. Reuses the exact same
|
|
// goneListeners subscribeTopicGone already exports, so ConversationView needs no changes at all.
|
|
// Live-confirmed follow-up bug: this path alone (unlike onDeleteTopic below) never actually
|
|
// cleared the SDK's own topic cache, so the redirect fired correctly but the dissolved group kept
|
|
// lingering in the sidebar as a broken stub ("g grpMc5uN_H6ASc 2人 1970/1/1") until markTopicGoneAndNotify
|
|
// was made to cover this path too.
|
|
const groupEvent = info as { what?: string; event?: string } | undefined;
|
|
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
|
|
markTopicGoneAndNotify(topic.name);
|
|
}
|
|
// docs/IM-前后端对接文档.md:416,1256: recall/edit push a custom `{info what:"im",
|
|
// event:"message.recalled"|"message.edited", payload:{seq,version}}` to every other ONLINE
|
|
// topic member -- explicitly NOT a native Tinode mechanism (`_routeInfo`'s switch has no `"im"`
|
|
// case, confirmed reading tinode.dev.js the same way the group.dissolved gap above was), so
|
|
// nothing here ever picked it up before. `notify()` above still fired (it's unconditional), so a
|
|
// component watching this topic WOULD re-render -- but the SDK's own local message cache was
|
|
// never told the content changed, so it re-rendered the SAME stale pre-recall/pre-edit bubble.
|
|
// The doc's own instruction is explicit: "按 topic+seq+version 刷新本地消息,不要等待用户重新
|
|
// 进入会话才更新" -- live-confirmed this was exactly what was happening instead (recall/edit only
|
|
// ever showed up for the OTHER party after something unrelated caused a full resync, e.g.
|
|
// reopening the conversation). Reuses the exact `getMeta(...).withData(seq,seq+1,1)` targeted
|
|
// single-message refetch ConversationView's own jump-to-message feature already established.
|
|
if (info?.what === 'im' && (info.event === 'message.recalled' || info.event === 'message.edited')) {
|
|
const seq = info.payload?.seq;
|
|
if (typeof seq === 'number') {
|
|
topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {});
|
|
}
|
|
}
|
|
// docs/IM-前后端对接文档.md:1542 documents this push as `group.profile.updated`, but a real rename,
|
|
// live-instrumented (logging the raw `info` object as it arrived), showed the actual wire event is
|
|
// `{what:"group", event:"group.settings.updated"}` -- same doc-vs-wire drift already seen elsewhere
|
|
// in this backend. Matching the real event name fixes only half the bug: the sidebar/header name
|
|
// comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only
|
|
// mutation never touches for anyone, and the "obvious" fix -- `getMeta(startMetaQuery().withDesc()
|
|
// .build())`, the SDK's own sanctioned way to re-pull desc from the server -- was live-instrumented
|
|
// too and found to be genuinely broken against this backend: a consistent 504 timeout (~5.4s, every
|
|
// attempt, still failing 10+s after the write) on a brand-new topic, and on an established topic a
|
|
// fast-but-stale response whose `public.fn` still held the pre-rename name. Using the already-
|
|
// reliable REST `getGroup()` instead (confirmed live to return the fresh name immediately after a
|
|
// rename, every time) and applying it locally via `_processMetaDesc` -- the same internal method the
|
|
// SDK itself calls when a real `{meta desc}` response arrives, live-confirmed to correctly reach
|
|
// `me.contacts()` -- sidesteps the broken path entirely instead of routing through it.
|
|
if (groupEvent?.what === 'group' && groupEvent.event === 'group.settings.updated') {
|
|
getGroup(topic.name)
|
|
.then((data) => topic._processMetaDesc({ public: { fn: data.name, name: data.name } }))
|
|
.catch(() => {});
|
|
}
|
|
};
|
|
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 = () => {
|
|
markTopicGoneAndNotify(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 tinode = getTinode();
|
|
installUncachedInfoFallback(tinode);
|
|
const me = tinode.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;
|
|
repairContactReadState(topic);
|
|
const correctRead = getConfirmedRead(topicName);
|
|
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.
|
|
*/
|
|
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) {
|
|
chatListSnapshot.forEach(repairContactReadState);
|
|
return chatListSnapshot;
|
|
}
|
|
const byTopic = new Map<string, Contact>();
|
|
getTinode().mapTopics((topic) => {
|
|
if (!topic.name || !isChatListTopicName(topic.name) || goneTopicNames.has(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;
|
|
repairContactReadState(contact);
|
|
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(() => {});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Real, reproduced bug (2026-09-25 review): every module-level cache below is scoped by topic NAME,
|
|
* not by account -- and topic names collide across accounts by construction, not edge case (`'slf'`
|
|
* is the literal self-chat topic name for every account, `isChatListTopicName` below). Without this,
|
|
* logging out and into a DIFFERENT account in the same tab (no full reload -- resetTinode() itself
|
|
* never reloads the page) carries the previous account's cached state forward: `confirmedReadSeq`
|
|
* specifically was confirmed to silently mark the new account's genuinely-unread 'slf' messages as
|
|
* read the next time reconcileMeSubs() ticked, since it read the OLD account's higher confirmed seq
|
|
* as a floor for the new account's much-lower real one. Same reasoning extends to every other cache
|
|
* here: none of them should outlive the session that populated them.
|
|
*/
|
|
onTinodeReset(() => {
|
|
confirmedReadSeq.clear();
|
|
previewPrefetched.clear();
|
|
messageSnapshots.clear();
|
|
goneTopicNames.clear();
|
|
chatListSnapshot = null;
|
|
});
|
|
|
|
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);
|
|
}
|
|
// Separate, real bug this same guard doesn't cover (live-confirmed 2026-09-24, reported
|
|
// directly): sending your OWN message into a topic that's already open bumps `confirmedReadSeq`
|
|
// synchronously (ConversationView's noteRead effect), long before the async `{note read}` this
|
|
// relies on has actually reached the server. A `{get}` already in flight since before that
|
|
// moment can come back with a real, ADVANCED `seq` (the new message the server just accepted)
|
|
// paired with a `read` that hasn't caught up server-side yet -- `seq <= prev.seq` above is
|
|
// false (seq genuinely changed), so that guard correctly doesn't fire, and this spurious
|
|
// `unread:1` gets applied verbatim even though the user is the one who just sent it. Clamp
|
|
// `read` to what THIS client already knows it has confirmed seeing, regardless of what the
|
|
// fetch says -- a floor, never pushes `read` past the server's own value when the server is
|
|
// genuinely ahead of this record (e.g. read from a different, already-synced device).
|
|
const confirmed = getConfirmedRead(c.topic);
|
|
if (confirmed > (c.read || 0)) {
|
|
c.read = confirmed;
|
|
if ((c.recv || 0) < confirmed) c.recv = confirmed;
|
|
c.unread = Math.max(0, (c.seq || 0) - confirmed);
|
|
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);
|
|
};
|
|
}
|