diff --git a/web/src/features/groups/GroupDetailColumn.tsx b/web/src/features/groups/GroupDetailColumn.tsx
index 4f77f137..c1d21b93 100644
--- a/web/src/features/groups/GroupDetailColumn.tsx
+++ b/web/src/features/groups/GroupDetailColumn.tsx
@@ -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 }:
-
- {avatarLetter(groupQuery.data?.name || peerName)}
-
+
{groupQuery.data?.name || peerName}
@@ -274,11 +293,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
desktop's 40px (unconditional here before this fix). */}
-
- {avatarLetter(m.display_name)}
-
+
{m.display_name || m.user_id}
diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts
index 8f24d831..8ba30414 100644
--- a/web/src/lib/tinode/topics.ts
+++ b/web/src/lib/tinode/topics.ts
@@ -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();
/**
- * 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);
}