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); + }; +}