Files
im_webapp/web/src/features/messages/HistorySearchModal.tsx
T
Your NameandClaude Sonnet 5 105ff76959 排查转发bug同类问题:修复查找聊天记录崩溃 + 复制功能复制出"undefined"
审查了所有"读取消息content做数据处理"的地方,找到两处和转发bug同一根因
(手机端扁平非Drafty内容格式)的真实问题:

1. 查找聊天记录(HistorySearchModal)的搜索结果渲染,对手机端发送的图片/文件/
   语音/视频/名片消息命中关键词时会让整个应用崩溃——Drafty.toPlainText在这
   类扁平内容上返回undefined,highlightMatches对undefined调用.toLowerCase()
   直接抛异常,而这个应用完全没有错误边界(ErrorBoundary)兜底,等于白屏。
   已用真实搜索接口返回确认snapshot里其实带着head字段(此前SearchHit类型
   没建模),据此在渲染前识别这几种扁平内容并显示[图片]/[文件]/[语音]/[名片]
   这样的占位文案,不再无脑喂给renderDrafty;顺手给highlightMatches补了防御
   性判空,避免类似疏漏再次崩溃整个应用。

2. 消息右键菜单"复制"功能,对手机端发送的扁平文本消息(如群公告自动发布的
   那条)和图片/文件/语音/视频消息,复制到剪贴板的是字面字符串"undefined"。
   文本类修成正确提取真实文本;附件类直接从"复制"这个操作里去掉(参考名片/
   通话记录消息已有的排除逻辑),复制一段JSON式的附件数据本来就没有意义。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:06:10 +08:00

422 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getAllGroupMembers } from '@/lib/api/groups';
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
type Tab = 'text' | MediaType;
const TABS: { key: Tab; label: string; icon: string }[] = [
{ key: 'text', label: '聊天记录', icon: 'search' },
{ key: 'image', label: '图片', icon: 'image' },
{ key: 'video', label: '视频', icon: 'videocam' },
{ key: 'file', label: '文件', icon: 'description' },
{ key: 'link', label: '链接', icon: 'link' },
];
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
const data = item.ent?.[0]?.data;
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
const src = inline || remote.url;
return (
<div className="relative aspect-square overflow-hidden bg-surface-rail">
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? (
<div className="absolute inset-0 bg-black/10">
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
{data?.duration ? (
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</div>
) : null}
</div>
);
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
* through rather than picking fields back out of it by hand. The previous version's manual
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
* search against a real plain-text message showed before this fix.
*
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
* on it -- this app has no error boundary anywhere to contain that). Same root cause, and same fix
* approach, as `forwardMessage()`'s own `flatForwardPayload` (lib/tinode/send.ts). */
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
const mobileAttachment = mobileAttachmentContentOf(asMessage);
if (mobileAttachment) {
const label =
mobileAttachment.kind === 'image'
? '[图片]'
: mobileAttachment.kind === 'video'
? '[视频]'
: mobileAttachment.kind === 'voice'
? '[语音]'
: `[文件] ${mobileAttachment.name || ''}`;
return <span className="text-text-body">{label}</span>;
}
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
* whose inline links aren't gated here (same as any other chat-bubble link).
*/
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
if (!item.url) return <SnapshotContent item={item} />;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
return (
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
<button
type="button"
onClick={open}
disabled={safetyCheck.isPending}
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
>
{item.url}
</button>
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
</div>
);
}
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
{loading ? '加载中…' : '加载更多'}
</button>
);
}
interface HistorySearchModalProps {
topicId: string;
initialTab?: Tab;
onClose: () => void;
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
isGroup: boolean;
peerName: string;
}
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEscapeKey(onClose);
const myUserId = useSessionStore((s) => s.myUserId);
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId),
enabled: isGroup,
});
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
// prefix removal, not a lossy truncation.
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
return peerName;
};
const searchQuery = useInfiniteQuery({
queryKey: ['topic-search', topicId, debouncedQuery],
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
});
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
const mediaQuery = useInfiniteQuery({
queryKey: ['topic-media', topicId, tab],
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'image' || tab === 'video',
});
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
// header element sitting above all four tabs, not a 消息-tab-only control.
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'file' || tab === 'link',
});
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
// hands off the target and gets out of the way.
const jumpToMessage = (seq: number) => {
useMessageJumpStore.getState().requestJump(topicId, seq);
onClose();
};
return (
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
// exactly as before.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
<div
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
onClick={(e) => e.stopPropagation()}
>
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
{query ? (
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
</button>
) : null}
</div>
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
取消
</button>
</header>
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
}`}
>
<Icon name={t.icon} className="text-[16px]" />
{t.label}
</button>
))}
</div>
<div className="hidden flex-none px-5 py-3 md:block">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
</div>
</div>
{tab === 'text' ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-5 pb-4">
{!debouncedQuery.trim() ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
) : searchQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
) : (
<>
<ul className="flex flex-col gap-1">
{searchItems.map((item) => {
const fullFrom = normalizeSender(item.from);
const name = senderLabel(item.from);
return (
<li key={item.seq}>
<button
type="button"
onClick={() => jumpToMessage(item.seq)}
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
>
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
}`}
>
{avatarLetter(name)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span className="truncate text-text-tertiary-2">{name}</span>
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="truncate">
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
</div>
</div>
</button>
</li>
);
})}
</ul>
{searchQuery.hasNextPage ? (
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
</div>
) : tab === 'image' || tab === 'video' ? (
<div className="flex-1 overflow-y-auto p-5">
{mediaQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : mediaQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !mediaItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{mediaItems.map((item) => (
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
{mediaQuery.hasNextPage ? (
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : fileLinkQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !fileLinkItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="flex flex-col gap-2">
{fileLinkItems.map((item) =>
tab === 'link' ? (
<LinkResult key={item.seq} item={item} />
) : (
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
),
)}
</div>
{fileLinkQuery.hasNextPage ? (
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
)}
</div>
</div>
);
}