群聊生命周期事件对被动成员实时不可见——前端兜底同步(真正的根因在后端,无法单靠前端代码彻底解决)

读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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 18:55:04 +08:00
co-authored by Claude Sonnet 5
parent d1f3aea26e
commit ab3843dbdb
2 changed files with 61 additions and 1 deletions
+10 -1
View File
@@ -5,7 +5,7 @@ import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast'; import { Toast } from '@/app/Toast';
import { restoreSession } from '@/lib/tinode/connection'; import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors'; import { isTenantMismatch } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics'; import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session'; import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying /** 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('会话列表加载失败,请刷新页面重试')); subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]); }, [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 <Navigate to="/tenant" replace />; if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) { if (myUserId) {
return ( return (
+51
View File
@@ -355,3 +355,54 @@ onReauthenticated(() => {
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {}); topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
} }
}); });
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;
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);
};
}