From 804618ad634f566356d1a34cd0170da6f734add6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 23:36:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BE=A4=E8=81=8A=E8=A7=A3?= =?UTF-8?q?=E6=95=A3=E9=80=9A=E7=9F=A5=E8=A6=86=E7=9B=96=E4=B8=8D=E5=85=A8?= =?UTF-8?q?=EF=BC=9A=E4=B8=BB=E5=8A=A8=E9=80=80=E5=87=BA/=E8=A7=A3?= =?UTF-8?q?=E6=95=A3=E7=BE=A4=E8=81=8A=E4=BC=9A=E8=AF=AF=E6=8A=A5"?= =?UTF-8?q?=E8=AF=A5=E7=BE=A4=E8=81=8A=E5=B7=B2=E8=A7=A3=E6=95=A3"?= =?UTF-8?q?=E4=B8=94=E7=95=99=E4=B8=8B=E5=B9=BD=E7=81=B5=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义 {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的头像修复,这个文件因为改动集中放在了一起)。 --- web/src/features/groups/GroupDetailColumn.tsx | 42 ++++++--- web/src/lib/tinode/topics.ts | 89 ++++++++++++++----- 2 files changed, 100 insertions(+), 31 deletions(-) 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); }