diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 9b0a3258..596df97e 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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(() => {}); }; diff --git a/web/src/lib/tinode/errors.ts b/web/src/lib/tinode/errors.ts index fb80a207..acfd9ecf 100644 --- a/web/src/lib/tinode/errors.ts +++ b/web/src/lib/tinode/errors.ts @@ -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; diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index 172c5736..eef6489a 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -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; createAccountBasic( diff --git a/web/src/lib/tinode/topics.ts b/web/src/lib/tinode/topics.ts index 9750f63f..8f24d831 100644 --- a/web/src/lib/tinode/topics.ts +++ b/web/src/lib/tinode/topics.ts @@ -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(); + +/** + * 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(); 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 --