修复刷新/深链进入已解散的群聊时显示错乱,改成直接跳回消息列表

用户实机截图报告:一个群被解散后,如果直接刷新页面(或深链)停留在这个群的
URL上,标题栏/侧边栏显示原始topic id(不是群名)、"1970/1/1"的时间戳、
"0人"成员数,而不是跳转离开。

这跟已有的"正在查看时被实时解散"处理(subscribeTopicGone/onDeleteTopic)
不是同一条路径——那个只覆盖"已经订阅成功、活跃看着的时候被解散",覆盖不了
"这次页面加载压根没订阅成功过"的情况。实机复现确认:openConversation()对
一个已解散群的{sub}请求会正确收到404 CommError拒绝,但ConversationView
原来的catch只弹一个通用的"加载会话失败,请重试"提示,不会跳转,会话视图就
停留在一个半初始化的空壳状态。

修复:新增isTopicGone()识别这个404,命中后跟"实时解散"走一样的用户体验
(toast提示+跳转回/messages)。同时发现并修复了一个连带的清理问题——单纯
调用getTinode().getTopic(name)(openConversation内部一开始就会调,不管
后面订阅成不成功)就已经在SDK本地缓存里留下一个空壳对象,光是这个空壳存在
就足以让侧边栏显示出这条错乱的记录;而清理这个空壳时又跟ConversationView
自己卸载时的closeConversation()清理逻辑(它自己也会重新调用getTopic())
产生竞态——直接删除后又被重新创建。改成维护一个"确认已消失"的名单,让聊天
列表的过滤逻辑直接按名字永久排除,不再依赖谁先执行、谁后执行。

实机验证:真实解散一个群后,直接deep-link/刷新回这个群的URL——确认页面
立即跳转回/messages,且侧边栏不再出现这条已解散群的残留记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 22:49:17 +08:00
co-authored by Claude Sonnet 5
parent c53e6552eb
commit df22f26628
4 changed files with 78 additions and 2 deletions
+22 -1
View File
@@ -27,11 +27,13 @@ import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import {
closeConversation,
ensureTopicAttached,
forgetTopic,
markConfirmedRead,
openConversation,
setActiveConversationTopic,
@@ -260,7 +262,26 @@ export function ConversationView() {
me.getMeta(me.startMetaQuery().withOneSub(undefined, topic.name).build()).catch(() => {});
}
})
.catch((err) => showError(err, '加载会话失败,请重试'));
.catch((err) => {
// A group dissolved before this specific page load ever subscribed to it -- distinct from
// subscribeTopicGone below, which only covers watching it get dissolved live while already
// subscribed. See isTopicGone's own comment for the real, reported bug this closes (a
// deep-link/refresh into an already-dissolved group left the screen sitting on a raw-topic-id,
// "1970/1/1", "0 members" stub instead of leaving). Same user-facing outcome as the live case
// (toast + bounce to the chat list) for consistency, not a special "this group doesn't exist"
// message -- from this user's vantage point both cases are just "this group is gone now".
if (isTopicGone(err)) {
// The failed subscribe attempt above already left a bare, empty stub for this topic in the
// SDK's cache (getTopic() creates one just by being called, before it knows whether the
// subscribe will succeed) -- without this, that stub alone is enough to make the chat list
// keep showing this now-gone group as a broken-looking row even after navigating away.
forgetTopic(topicId);
notify('该群聊已解散');
navigate('/messages', { replace: true });
return;
}
showError(err, '加载会话失败,请重试');
});
return () => {
closeConversation(topicId).catch(() => {});
};
+17
View File
@@ -39,6 +39,23 @@ export function isTenantMismatch(err: unknown): boolean {
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
}
/**
* `openConversation()`'s `{sub}` genuinely rejects with a clean `CommError` (code 404, message
* "topic not found (404)") for a topic that no longer exists -- confirmed live: a group dissolved
* by its owner, then deep-linked/refreshed into directly by a former member (not the "I'm actively
* watching it get dissolved right now" case subscribeTopicGone already covers), reproducibly 404s
* exactly like this. Before this check existed, ConversationView's generic catch just showed
* "加载会话失败,请重试" and left the screen sitting on a half-initialized topic stub (raw topic id
* as the name, "1970/1/1" for `touched`, "0" members -- `getTinode().getTopic()` lazily creates that
* stub the moment anything asks for the name, whether or not the subscribe behind it ever succeeds),
* a real reported bug ("当打开的群聊被删除后, 刷新还在群聊中 但是显示的不对"). 404 alone is treated
* as sufficiently unambiguous for "this topic doesn't exist" -- there's no other real reason a
* `{sub}` would come back with this exact code.
*/
export function isTopicGone(err: unknown): boolean {
return (err as { code?: number })?.code === 404;
}
export function loginErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
if (known) return known;
+8
View File
@@ -309,6 +309,14 @@ declare module 'tinode-sdk' {
* which additionally filters by `isCommType()` -- see topics.ts's `getChatListSnapshot` for why
* that filter can't be trusted for this backend's P2P topics. */
mapTopics(callback: (topic: Topic, key: string) => void): void;
/** Public (not underscore-prefixed) on the real SDK, confirmed reading tinode.dev.js -- removes a
* topic from the local cache entirely, same cleanup the SDK's own `{pres what:"gone"}` handling
* uses internally. Needed for a topic that was never successfully subscribed at all (a 404 on
* first {sub}, e.g. deep-linking into an already-dissolved group) -- `getTopic()` having been
* called even once already left a bare, empty stub in the cache by then (see topics.ts's
* openConversation/getChatListSnapshot for why that stub alone is enough to show up in the chat
* list looking broken), and nothing else ever clears a topic that was never truly attached. */
cacheRemTopic(topicName: string): void;
account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise<unknown>;
createAccountBasic(
+31 -1
View File
@@ -122,6 +122,36 @@ export function subscribeTopicGone(callback: (topicName: string) => void): () =>
return () => goneListeners.delete(callback);
}
/**
* 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.
*/
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.
*/
export function forgetTopic(topicName: string): void {
goneTopicNames.add(topicName);
getTinode().cacheRemTopic(topicName);
notifyTopic(topicName);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
@@ -389,7 +419,7 @@ export function getChatListSnapshot(): Contact[] {
if (chatListSnapshot) return chatListSnapshot;
const byTopic = new Map<string, Contact>();
getTinode().mapTopics((topic) => {
if (!topic.name || !isChatListTopicName(topic.name)) return;
if (!topic.name || !isChatListTopicName(topic.name) || goneTopicNames.has(topic.name)) return;
prefetchPreview(topic.name);
// `.topic` (what every consumer of this list actually reads) normally arrives via
// `_processMetaSubs` merging the server's own `sub.topic` field onto this same cached instance --