diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx
index 2651de4a..dcff6292 100644
--- a/web/src/app/RequireAuth.tsx
+++ b/web/src/app/RequireAuth.tsx
@@ -5,7 +5,7 @@ import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors';
-import { subscribeMeTopic } from '@/lib/tinode/topics';
+import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
@@ -81,6 +81,15 @@ export function RequireAuth() {
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]);
+ // Separate from the one-time subscribeMeTopic effect above on purpose: this one has real
+ // start/stop semantics (a timer + listeners, not a non-idempotent WS packet) and needs to
+ // restart cleanly across StrictMode's mount->cleanup->remount, not be guarded against it. See
+ // startMeReconciliation's own comment for what backend gap this is mitigating.
+ useEffect(() => {
+ if (!myUserId) return undefined;
+ return startMeReconciliation();
+ }, [myUserId]);
+
if (!tenant) return ;
if (myUserId) {
return (
diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts
index 85bbf863..0e783abf 100644
--- a/web/src/lib/tinode/topics.ts
+++ b/web/src/lib/tinode/topics.ts
@@ -355,3 +355,54 @@ onReauthenticated(() => {
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
}
});
+
+const ME_RECONCILE_INTERVAL_MS = 30_000;
+let meReconcileTimer: ReturnType | 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;
+ me.getMeta(me.startMetaQuery().withLaterSub().build()).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);
+ };
+}