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
+12 -2
View File
@@ -8,6 +8,7 @@ import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
@@ -55,6 +56,9 @@ export function ChatListColumn() {
const location = useLocation();
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
// key so this is a cache read, not an extra request, in the common case that already mounted it.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const tinode = useTinode();
const contacts = useChatList();
const bootstrap = useBootstrap();
@@ -70,7 +74,13 @@ export function ChatListColumn() {
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
});
const trimmedQuery = query.trim();
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const filtered = trimmedQuery
? sorted.filter((c) =>
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : c.public?.fn || c.topic || '')
.toLowerCase()
.includes(trimmedQuery.toLowerCase()),
)
: sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
return (
@@ -127,7 +137,7 @@ export function ChatListColumn() {
</div>
) : (
filtered.map((contact) => {
const name = contact.public?.fn || contact.topic;
const name = contact.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
+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"
+9 -1
View File
@@ -11,6 +11,7 @@ import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
@@ -107,7 +108,14 @@ export function MessageRow({
const contactCard = contactCardContentOf(msg);
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
if (typeof msg.seq !== 'number') return;
const seq = msg.seq;
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
// exact same way again.
ensureTopicAttached(topic.name)
.then(() => retryMessage(topic, seq))
.catch(() => onError?.('重试发送失败'));
};
return (