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:
co-authored by
Claude Sonnet 5
parent
e78437cb2a
commit
c3e8f91364
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user