修复群聊解散通知覆盖不全:主动退出/解散群聊会误报"该群聊已解散"且留下幽灵会话

前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义
{info what:"group" event:"group.dissolved"}事件而非原生{pres what:"gone"},
需要单独识别)。这次用户实测发现同一个"话题彻底消失后清理不干净"的问题在另外
两条路径上都复现了:自己点"退出群聊"、自己(群主)点"解散群聊"。

根因:这两个操作各自的mutation.onSuccess只是navigate('/messages'),没有做任何
本地缓存清理;而服务端在REST调用成功后,通常还会异步地给这条已经不再属于你的
订阅补发一个原生/自定义的"gone"信号——这个信号触发的是topics.ts里那条本该只用
于"被动围观"场景的goneListeners通知链,于是ConversationView的通用"该群聊已
解散"提示被复用到了并不贴切的"我自己退出/解散"场景上,且同一个SDK缓存stub没
被清理的老毛病(getTopic()被再次调用就会重建一个空壳)也照样在这两条路径上
重演,表现为消息列表里多出一个显示原始群ID、"1970/1/1"的幽灵会话。

修复:
- topics.ts的markTopicGoneAndNotify新增goneTopicNames去重判断——只要
  forgetTopic已经先标记过这个话题,稍后到达的原生/自定义gone信号就直接跳过
  (包括不再触发goneListeners),避免重复通知。
- GroupDetailColumn.tsx的leaveMutation/disbandMutation改成onSuccess里先调用
  forgetTopic(topicId)(清缓存+加入排除名单+让上面的守卫生效)再navigate,
  不再空等异步信号来做清理。

实机验证:systest04建群拉systest02入群 -> systest02主动退出,返回消息列表后
侧栏不再残留幽灵会话;systest04(群主)解散同一个群,同样返回消息列表且侧栏
干净。顺带在GroupDetailColumn.tsx里把群头像/成员格子头像接上了真实上传头像
(见下一条commit的头像修复,这个文件因为改动集中放在了一起)。
This commit is contained in:
Your Name
2026-09-16 23:36:35 +08:00
parent df22f26628
commit 804618ad63
2 changed files with 100 additions and 31 deletions
+69 -20
View File
@@ -123,28 +123,57 @@ export function subscribeTopicGone(callback: (topicName: string) => void): () =>
}
/**
* Topics confirmed 404-gone (see forgetTopic below) that `getChatListSnapshot` must never show again
* THIS session, no matter what re-creates a cache entry for the name afterward -- live-confirmed real
* race: `openConversation`'s own cleanup (`closeConversation`, fired by ConversationView's unmount
* effect as the redirect below unmounts it) calls `getTinode().getTopic(topicName)` again for its
* `.leave(false)` -- unconditionally, with no way to know this specific name should stay forgotten --
* which by itself re-creates a fresh, empty stub the instant `cacheRemTopic` below has removed the old
* one, silently undoing the cleanup and leaving the exact same broken sidebar row behind. Excluding by
* name in the chat-list filter itself sidesteps that ordering race entirely rather than trying to make
* `forgetTopic` run strictly after every other thing that might touch this topic's cache entry.
* Topics confirmed gone that `getChatListSnapshot` must never show again THIS session, no matter what
* re-creates a cache entry for the name afterward -- live-confirmed real race: `openConversation`'s
* own cleanup (`closeConversation`, fired by ConversationView's unmount effect as the redirect below
* unmounts it) calls `getTinode().getTopic(topicName)` again for its `.leave(false)` -- unconditionally,
* with no way to know this specific name should stay forgotten -- which by itself re-creates a fresh,
* empty stub the instant `cacheRemTopic` below has removed the old one, silently undoing the cleanup
* and leaving the exact same broken sidebar row behind. Excluding by name in the chat-list filter
* itself sidesteps that ordering race entirely rather than trying to make cleanup run strictly after
* every other thing that might touch this topic's cache entry.
*/
const goneTopicNames = new Set<string>();
/**
* For a topic that was never successfully subscribed at all -- e.g. `openConversation()` rejecting
* with a 404 (see ConversationView's `isTopicGone` handling) because it was deep-linked/refreshed
* into after already being dissolved. Distinct from the live-dissolve path above (`onDeleteTopic`/
* `_gone()`, which does its own SDK-side cache cleanup already): merely CALLING `getTopic(name)` --
* which `openConversation` always does before it even knows whether the subscribe will succeed --
* already leaves a bare, empty stub topic sitting in the SDK's cache, and nothing else ever clears
* one that was never truly attached. Left alone, that stub is enough on its own to make the chat list
* show a broken-looking row (raw topic id for a name, no `touched`) for a group that, from this
* client's perspective, should simply not exist.
* Shared by every "this topic is confirmed gone" path below -- native `onDeleteTopic` (a real `{pres
* what:"gone"}`, which never actually happens for THIS backend's own group dissolve -- see the
* `group.dissolved` handling in wireTopicCallbacks' `onInfo` for why) and that custom path itself both
* call this, so cache cleanup can't accidentally happen on only one of them. Removing the topic from
* the SDK's cache matters even here (not just for the 404-before-ever-subscribing case forgetTopic
* covers) -- confirmed live: a passive member watching a group when someone else dissolves it now
* correctly gets bounced to /messages the instant it happens, but the group still lingered in their
* chat list afterward (raw topic id, "1970/1/1") until this was added, same broken-stub symptom, just
* reached via the live path instead of the refresh-into-already-gone one.
*
* Skips everything (including `goneListeners`) once `goneTopicNames` already has this name -- real,
* user-reported bug: leaving a group yourself (GroupDetailColumn's 退出群聊/解散群聊, which calls
* `forgetTopic` synchronously the instant its own REST call succeeds, see below) still leaves the
* server free to ALSO deliver a native/`group.dissolved` gone-signal for that same topic a moment
* later, since from the server's point of view your own subscription really did just end too. Left
* unguarded, that late-arriving signal would re-run `goneListeners` a second time for a topic already
* handled -- showing ConversationView's "该群聊已解散" toast (wrong wording for a leave you chose
* yourself, not a dissolve) on top of whatever that action's own success handler already did.
*/
function markTopicGoneAndNotify(topicName: string) {
if (goneTopicNames.has(topicName)) return;
goneTopicNames.add(topicName);
getTinode().cacheRemTopic(topicName);
notifyTopic(topicName);
goneListeners.forEach((cb) => cb(topicName));
}
/**
* For any topic THIS client already knows is gone without needing to be told by a server push --
* `openConversation()` rejecting with a 404 (see ConversationView's `isTopicGone` handling) because it
* was deep-linked/refreshed into after already being dissolved, or GroupDetailColumn's own 退出群聊/
* 解散群聊 success handlers calling this immediately rather than waiting on the async gone-signal that
* usually follows. Doesn't go through markTopicGoneAndNotify's `goneListeners` trigger -- every caller
* already has its own richer, action-specific way to notify+navigate (a 404 catch block with the real
* `err`; a mutation's own `onSuccess`), so firing goneListeners too would just re-run that a second
* time, and for the leave/disband callers specifically, with the WRONG generic "该群聊已解散" wording.
* Adding to `goneTopicNames` here first is exactly what makes markTopicGoneAndNotify's own guard above
* effective against whatever late server push for this same topic follows.
*/
export function forgetTopic(topicName: string): void {
goneTopicNames.add(topicName);
@@ -159,6 +188,27 @@ function wireTopicCallbacks(topic: Topic) {
topic.onInfo = (info) => {
notify();
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
// Real, live-confirmed gap (user-reported: "被解散当时没有" [redirect] -- only a later manual
// refresh ever caught it): this backend's own group-dissolve notification (server/
// im_group_events.go's publishIMGroupRealtimeEvent) travels as a custom `{info what:"group"
// event:"group.dissolved"}` packet, NOT the native Tinode `{pres what:"gone"}` the SDK's own
// `_gone()`/`onDeleteTopic` chain below is built around. Confirmed by reading tinode.dev.js's
// `_routeInfo`: `what:"group"` matches none of its explicit switch cases (recv/read/kp/kpa/kpv/
// call), so nothing native reacts to it -- `onInfo(info)` still fires right after regardless, but
// until now nothing here recognized this specific shape either. A member who was ACTIVELY,
// PASSIVELY watching a group (never the one who dissolved it themselves -- that flow already
// navigates away on its own mutation success, which is why testing only as the dissolver always
// looked correct) got no live signal at all: only a later refresh, hitting openConversation's 404
// path (see ConversationView's isTopicGone), ever discovered it was gone. Reuses the exact same
// goneListeners subscribeTopicGone already exports, so ConversationView needs no changes at all.
// Live-confirmed follow-up bug: this path alone (unlike onDeleteTopic below) never actually
// cleared the SDK's own topic cache, so the redirect fired correctly but the dissolved group kept
// lingering in the sidebar as a broken stub ("g grpMc5uN_H6ASc 2人 1970/1/1") until markTopicGoneAndNotify
// was made to cover this path too.
const groupEvent = info as { what?: string; event?: string } | undefined;
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
markTopicGoneAndNotify(topic.name);
}
};
topic.onMetaDesc = notify;
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
@@ -175,8 +225,7 @@ function wireTopicCallbacks(topic: Topic) {
topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify;
topic.onDeleteTopic = () => {
notify();
goneListeners.forEach((cb) => cb(topic.name));
markTopicGoneAndNotify(topic.name);
};
wiredTopics.add(topic);
}