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 : ( <>