From ab3843dbdb3b4f49815e0845c3199a95cff8829e Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 18:55:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BE=A4=E8=81=8A=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E4=BA=8B=E4=BB=B6=E5=AF=B9=E8=A2=AB=E5=8A=A8=E6=88=90?= =?UTF-8?q?=E5=91=98=E5=AE=9E=E6=97=B6=E4=B8=8D=E5=8F=AF=E8=A7=81=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=89=8D=E7=AB=AF=E5=85=9C=E5=BA=95=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=EF=BC=88=E7=9C=9F=E6=AD=A3=E7=9A=84=E6=A0=B9=E5=9B=A0=E5=9C=A8?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=8C=E6=97=A0=E6=B3=95=E5=8D=95=E9=9D=A0?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BB=A3=E7=A0=81=E5=BD=BB=E5=BA=95=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 读im_be的server/im_group_events.go确认:群聊创建/加人/踢人/解散/转让群主等全部事件都走 publishIMGroupRealtimeEvent,这个函数只会广播给"当前已订阅该群topic的会话"——对比P2P场景专门 有一条deliverIMConversationSystemMessageToP2PSessions,会无视订阅状态强制投递给双方的session, 群聊从未做过同等实现。实机验证:一个全新创建、成员从未打开过的群,即使对方账号完全在线、页面就 停留在/messages上,群条目和未读角标都不会出现,等多久都不会自愈,必须手动刷新页面。 由于这个topic客户端本来就不知道存在,没有任何东西可以"响应",前端无法真正修复,只能缓解:复用 subscribeMeTopic()/onReauthenticated()已经在用的withLaterSub()增量语义,改用getMeta()(不像 subscribe()那样在_attached为true时直接短路)定时(30秒)+ 切回标签页时主动拉一次"me"话题的最新 订阅列表。 实机验证:创建一个全新群、只邀请一个从未见过这个群的被动成员,在其页面保持被动、不做任何操作/ 刷新的情况下——模拟切换标签页触发的focus/visibilitychange事件后约十几秒内,群条目正确出现在 列表顶部;另一次未受干预的测试中,纯靠30秒定时器本身也确认能自愈。 Co-Authored-By: Claude Sonnet 5 --- web/src/app/RequireAuth.tsx | 11 +++++++- web/src/lib/tinode/topics.ts | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) 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); + }; +}