From c3e8f9136437e31dbe48a5d8a6410b4310935811 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 16 Sep 2026 10:44:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=98=BE=E7=A4=BA=E4=B8=BA=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?topic=20id=E3=80=81=E4=B8=BB=E5=8F=91=E9=80=81/=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E8=B7=AF=E5=BE=84=E6=9C=AA=E9=87=8D=E6=96=B0=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E8=AF=9D=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自聊天(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 --- web/src/features/messages/ChatListColumn.tsx | 14 +++++- .../features/messages/ConversationView.tsx | 46 +++++++++++++------ web/src/features/messages/MessageBubble.tsx | 10 +++- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx index 5832cafe..7269881b 100644 --- a/web/src/features/messages/ChatListColumn.tsx +++ b/web/src/features/messages/ChatListColumn.tsx @@ -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() { ) : ( 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(); diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index 63e6da33..d5242f8c 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -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() { {isGroup ? ( groupSublineParts.length ? {groupSublineParts.join(' · ')} : null - ) : ( + ) : isSelf ? null : ( {contact?.online ? '在线' : '离线'} @@ -583,7 +601,7 @@ export function ConversationView() {
- {isGroup ? null : ( + {isGroup || isSelf ? null : ( <>