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 { fetchAllPages } from '@/lib/api/client';
|
||||||
import { getGroups } from '@/lib/api/groups';
|
import { getGroups } from '@/lib/api/groups';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||||
import { formatListTime } from '@/lib/messages/time';
|
import { formatListTime } from '@/lib/messages/time';
|
||||||
@@ -55,6 +56,9 @@ export function ChatListColumn() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const myUserId = useSessionStore((s) => s.myUserId);
|
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 tinode = useTinode();
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
const bootstrap = useBootstrap();
|
const bootstrap = useBootstrap();
|
||||||
@@ -70,7 +74,13 @@ export function ChatListColumn() {
|
|||||||
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
|
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
|
||||||
});
|
});
|
||||||
const trimmedQuery = query.trim();
|
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);
|
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +137,7 @@ export function ChatListColumn() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((contact) => {
|
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 preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||||
const active = topicId === contact.topic;
|
const active = topicId === contact.topic;
|
||||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
|||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
||||||
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
||||||
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||||
import { useClickOutside } from '@/lib/hooks';
|
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 --
|
// 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.
|
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
|
||||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
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({
|
const membersQuery = useQuery({
|
||||||
queryKey: ['group-members', topicId],
|
queryKey: ['group-members', topicId],
|
||||||
queryFn: () => getAllGroupMembers(topicId || ''),
|
queryFn: () => getAllGroupMembers(topicId || ''),
|
||||||
@@ -272,7 +279,7 @@ export function ConversationView() {
|
|||||||
|
|
||||||
if (!topicId) return null;
|
if (!topicId) return null;
|
||||||
const topic = tinode.getTopic(topicId);
|
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;
|
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
|
// `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
|
// "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;
|
if (!text) return;
|
||||||
setDraft('');
|
setDraft('');
|
||||||
setMentionTrigger(null);
|
setMentionTrigger(null);
|
||||||
if (replyTo) {
|
const pendingReplyTo = replyTo;
|
||||||
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that also
|
setReplyTo(null);
|
||||||
// pings someone is a rare enough compound case that it isn't worth a second stitching path;
|
// The composer's own topic can be detached by the time this actually fires -- e.g. a
|
||||||
// the "@name" text still sends fine, just without the entity/highlight.
|
// disconnect/reconnect cycle resets every topic's attached state (see connection.ts's
|
||||||
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
|
// onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The
|
||||||
setReplyTo(null);
|
// forward flow above already guards against exactly this (see ensureTopicAttached's own
|
||||||
} else if (pendingMentions.length > 0) {
|
// comment for why it's the common case, not an edge case); the primary composer -- used far
|
||||||
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
|
// more often than forwarding -- had no equivalent guard at all.
|
||||||
} else {
|
ensureTopicAttached(topicId)
|
||||||
sendText(topic, text).catch((err) => showError(err, '发送失败'));
|
.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]: [] }));
|
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -574,7 +592,7 @@ export function ConversationView() {
|
|||||||
</strong>
|
</strong>
|
||||||
{isGroup ? (
|
{isGroup ? (
|
||||||
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
|
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'}`}>
|
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||||
{contact?.online ? '在线' : '离线'}
|
{contact?.online ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
@@ -583,7 +601,7 @@ export function ConversationView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isGroup ? null : (
|
{isGroup || isSelf ? null : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { renderDrafty } from '@/lib/messages/drafty';
|
|||||||
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||||
import { retryMessage } from '@/lib/tinode/send';
|
import { retryMessage } from '@/lib/tinode/send';
|
||||||
|
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||||
|
|
||||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||||
@@ -107,7 +108,14 @@ export function MessageRow({
|
|||||||
const contactCard = contactCardContentOf(msg);
|
const contactCard = contactCardContentOf(msg);
|
||||||
|
|
||||||
const retry = () => {
|
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 (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user