fix(web): 全新单聊(usr别名)会话消息收不到/在线状态错误——URL没跟上SDK重命名

真实两账号复现:好友刚加上、双方都还没发过消息的单聊,发消息表现为"发了
和没发一样"(无失败提示,但对方收不到),会话头部同时显示错误的裸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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 11:43:56 +08:00
co-authored by Claude Sonnet 5
parent 9adadaa57d
commit 0824502ddd
+25 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk'; import { Drafty } from 'tinode-sdk';
@@ -55,6 +55,7 @@ interface ContextMenuState {
export function ConversationView() { export function ConversationView() {
const { topicId } = useParams(); const { topicId } = useParams();
const navigate = useNavigate();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>(); const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
const myUserId = useSessionStore((s) => s.myUserId); const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode(); 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 // 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 // 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. // 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 () => { return () => {
closeConversation(topicId).catch(() => {}); closeConversation(topicId).catch(() => {});
}; };