From 0824502ddd922acfdff153a137d7b4ba30d8d7be Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 11:43:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=85=A8=E6=96=B0=E5=8D=95?= =?UTF-8?q?=E8=81=8A(usr=E5=88=AB=E5=90=8D)=E4=BC=9A=E8=AF=9D=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=94=B6=E4=B8=8D=E5=88=B0/=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=94=99=E8=AF=AF=E2=80=94=E2=80=94URL?= =?UTF-8?q?=E6=B2=A1=E8=B7=9F=E4=B8=8ASDK=E9=87=8D=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实两账号复现:好友刚加上、双方都还没发过消息的单聊,发消息表现为"发了 和没发一样"(无失败提示,但对方收不到),会话头部同时显示错误的裸ID和 "离线"(即使对方明明在线)。 根因(读tinode-sdk真实源码+原生协议脚本交叉验证,非猜测):这类会话从 "对方user_id"(usr...)这个地址进入,服务端订阅成功后会返回一个不同的 真实内部话题名(p2p...哈希)。SDK的Topic#subscribe在topic对象处于 `_new`状态时(每个新建对象默认如此)会把本地缓存从usr...改名到这个真 实名字——但本组件的submit()/头部展示/MessageBubble的retry()全都各自 独立调用`tinode.getTopic(topicId)`,用的是URL里那个没变过的usr...字符 串。改名发生后再调用`getTopic('usr...')`是一次缓存未命中,会凭空造出 一个全新的、从未真正订阅过的"替身"对象——发送/已读/在线状态全部作用在 这个空对象上,而真正收到数据的是已经改名的那个对象,两者从此互不相干。 用一个独立的原生WS协议脚本验证了根因:直接订阅+发布到同一个usr别名, 服务端确认消息真的送达并持久化(用一次全新订阅的历史记录能拉到),证 明"消息发不出去"是假象,真正的问题在于客户端读的从来不是收到数据的那 个对象。 修复:openConversation()解析出的topic对象一旦.name和URL参数不一致 (即刚刚被服务端改名),立刻用real name替换当前URL(replace,不留历 史记录)。这样一来,本组件后续所有`getTopic(topicId)`都会读到同一个、 真正接了收数据的对象,而不是每次都对着URL里那个过期字符串重新变出一 个空壳。 Co-Authored-By: Claude Sonnet 5 --- .../features/messages/ConversationView.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) 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(() => {}); };