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); }