fix(web): 修复自聊天显示为原始topic id、主发送/重试路径未重新订阅话题

自聊天(slf)会话头部/侧边栏/聊天列表此前直接回退显示原始topic id "slf"
(而不是自己的昵称),且会话头部还错误显示了通话/视频按钮和"离线"状态——
这三者都源自同一处:peerName的计算只查好友列表(不含自己),没有对slf
做特殊处理。DetailColumn此前已经用isSelf正确处理了详情栏内部的各项操
作(commit 9271a0a9),但ConversationView自己的头部渲染和ChatListColumn
的列表行都没有跟进。改为isSelf时读取AppShell/SettingsLayout已经在用的
同一个['me']缓存查询,取自己的真实昵称,并隐藏通话按钮和在线状态行。

真实两账号联调时发现更严重的问题:主输入框发送消息(ConversationView
submit())和失败重试(MessageBubble retry())都直接调用sendText/
retryMessage,从未像转发流程(见ensureTopicAttached自己的注释)那样先
确保话题处于attached状态——连接抖动一次(断线重连会重置每个话题的
attached状态)之后,不管是正常发送还是点击重试都会直接失败,不会自
动恢复。转发流程本来就有这个防护,这次给主发送路径和重试路径补上同
一个防护。

用两个真实测试账号(asn494180 与新建的e2etester1,跨web.118im.com/
web2.118im.com两个域名分别登录)完整验证:加好友、接受好友申请、
P2P发消息全链路,复现问题后确认修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-16 10:44:19 +08:00
co-authored by Claude Sonnet 5
parent e78437cb2a
commit c3e8f91364
3 changed files with 53 additions and 17 deletions
+32 -14
View File
@@ -18,6 +18,7 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
import { getMe } from '@/lib/api/profile';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks';
@@ -237,6 +238,12 @@ export function ConversationView() {
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
// `slf` (chatting with yourself, née "文件助手" -- see ChatListColumn.tsx's PINNED_ENTRIES
// comment on why that framing was deliberately dropped) isn't in your own contacts/friends list,
// so `contact` below is always undefined for it -- shares AppShell's/SettingsLayout's `['me']`
// query so this doesn't add a request beyond whichever of those mounts first.
const isSelf = topicId === 'slf';
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''),
@@ -272,7 +279,7 @@ export function ConversationView() {
if (!topicId) return null;
const topic = tinode.getTopic(topicId);
const peerName = contact?.public?.fn || topicId;
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId;
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
@@ -335,17 +342,28 @@ export function ConversationView() {
if (!text) return;
setDraft('');
setMentionTrigger(null);
if (replyTo) {
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that also
// pings someone is a rare enough compound case that it isn't worth a second stitching path;
// the "@name" text still sends fine, just without the entity/highlight.
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
setReplyTo(null);
} else if (pendingMentions.length > 0) {
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
} else {
sendText(topic, text).catch((err) => showError(err, '发送失败'));
}
const pendingReplyTo = replyTo;
setReplyTo(null);
// The composer's own topic can be detached by the time this actually fires -- e.g. a
// disconnect/reconnect cycle resets every topic's attached state (see connection.ts's
// onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The
// forward flow above already guards against exactly this (see ensureTopicAttached's own
// comment for why it's the common case, not an edge case); the primary composer -- used far
// more often than forwarding -- had no equivalent guard at all.
ensureTopicAttached(topicId)
.then((target) => {
if (pendingReplyTo) {
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that
// also pings someone is a rare enough compound case that it isn't worth a second
// stitching path; the "@name" text still sends fine, just without the entity/highlight.
return sendReply(target, text, pendingReplyTo, quotedSenderLabel(pendingReplyTo.from));
}
if (pendingMentions.length > 0) {
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
}
return sendText(target, text);
})
.catch((err) => showError(err, '发送失败'));
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
};
@@ -574,7 +592,7 @@ export function ConversationView() {
</strong>
{isGroup ? (
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
) : (
) : isSelf ? null : (
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
@@ -583,7 +601,7 @@ export function ConversationView() {
</button>
</div>
<div className="flex items-center gap-1">
{isGroup ? null : (
{isGroup || isSelf ? null : (
<>
<button
type="button"