diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 0e783abf..35cba77f 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -382,7 +382,43 @@ let meReconcileTimer: ReturnType | null = null; function reconcileMeSubs() { const me = getTinode().getMeTopic(); if (!me.isSubscribed()) return; - me.getMeta(me.startMetaQuery().withLaterSub().build()).catch(() => {}); + // 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(); + 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) continue; // brand-new topic to this session -- exactly what this mitigation is for + 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(() => {}); } /**