修复群聊解散通知覆盖不全:主动退出/解散群聊会误报"该群聊已解散"且留下幽灵会话
前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义
{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:
@@ -3,6 +3,7 @@ import { useLayoutEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
@@ -24,8 +25,8 @@ import {
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/groups';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { forgetTopic } from '@/lib/tinode/topics';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { requestActionToken } from '@/stores/securityChallenge';
|
||||
@@ -151,15 +152,30 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||||
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
|
||||
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
|
||||
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
|
||||
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
|
||||
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
|
||||
// late signal entirely once `forgetTopic` has already marked this name gone here.
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: () => leaveGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '退出群聊失败'),
|
||||
});
|
||||
|
||||
const disbandMutation = useMutation({
|
||||
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '解散群聊失败'),
|
||||
});
|
||||
|
||||
@@ -237,9 +253,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<div className="bg-white md:contents">
|
||||
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(groupQuery.data?.name || peerName)}
|
||||
</span>
|
||||
<Avatar
|
||||
id={topicId}
|
||||
name={groupQuery.data?.name || peerName}
|
||||
avatarFileId={groupQuery.data?.avatar_file_id}
|
||||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
@@ -274,11 +293,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
|
||||
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
|
||||
desktop's 40px (unconditional here before this fix). */}
|
||||
<span
|
||||
className={`grid h-11 w-11 place-items-center rounded-lg text-sm font-medium text-white md:h-10 md:w-10 ${avatarBgClass(m.user_id)}`}
|
||||
>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<Avatar
|
||||
id={m.user_id}
|
||||
name={m.display_name}
|
||||
avatarFileId={m.avatar_file_id}
|
||||
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
||||
/>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user