diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index d5242f8c..b87dfcba 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Link, useOutletContext, useParams } from 'react-router-dom'; +import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom'; import type { StoredMessage } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk'; @@ -55,6 +55,7 @@ interface ContextMenuState { export function ConversationView() { const { topicId } = useParams(); + const navigate = useNavigate(); const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext(); const myUserId = useSessionStore((s) => s.myUserId); const tinode = useTinode(); @@ -135,7 +136,29 @@ export function ConversationView() { // a totally blank message list with no error, no retry, and (since the noteRead effect below // only fires once `messages.length > 0`) a read receipt that can never be sent, leaving the chat // list's unread badge permanently stuck for a conversation that in fact never loaded at all. - openConversation(topicId).catch((err) => showError(err, '加载会话失败,请重试')); + openConversation(topicId) + .then((topic) => { + // A P2P conversation reached via the OTHER user's `usr...` id (the only address that exists + // before a real conversation has ever happened -- e.g. straight from a friend's profile, + // never yet in the chat list) gets handed back a DIFFERENT real topic name by the server + // (its actual `p2p...` hash) the moment the subscribe succeeds -- confirmed live: the SDK's + // own Topic#subscribe (tinode.dev.js) renames+re-keys its local cache entry from `usr...` to + // that real name whenever the topic was `_new` (true for every freshly-constructed Topic + // object, which this always is on first open). Every OTHER piece of this component -- + // submit()'s send, the header's presence lookup, MessageBubble's retry -- calls + // `tinode.getTopic(topicId)` fresh off the URL param on its own, completely independently of + // whatever object `openConversation` itself subscribed. Left alone, that second `getTopic` + // call is a cache MISS against the now-renamed key, silently manufacturing a brand new, + // never-subscribed, empty stand-in object -- every send/read/presence check from then on + // operates on that empty stand-in, not the real, subscribed, actually-receiving-data one. + // Real messages keep arriving successfully (confirmed via a raw-protocol repro: the {pub} + // lands and a fresh {sub}'s history dump shows it) but never reach this component's topic + // object, so the chat window shows nothing and looks like sending silently failed. Replacing + // the URL to the real name the instant it's known makes every later `getTopic(topicId)` in + // this component resolve the SAME object subscribe() just populated, not a fresh impostor. + if (topic.name !== topicId) navigate(`/messages/${topic.name}`, { replace: true }); + }) + .catch((err) => showError(err, '加载会话失败,请重试')); return () => { closeConversation(topicId).catch(() => {}); };