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 (
{src ? ( ) : (
)} {isVideo && src ? (
{data?.duration ? ( {formatDuration(data.duration)} ) : null}
) : null}
); } /** 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 flat-vs-Drafty shape * distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already * document for message bubbles. */ function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) { const content = item.snapshot?.content; if (!content) return (内容不可用); const asMessage = { content, head: item.snapshot?.head } as Parameters[0]; const mobileAttachment = mobileAttachmentContentOf(asMessage); if (mobileAttachment) { const label = mobileAttachment.kind === 'image' ? '[图片]' : mobileAttachment.kind === 'video' ? '[视频]' : mobileAttachment.kind === 'voice' ? '[语音]' : `[文件] ${mobileAttachment.name || ''}`; return {label}; } if (contactCardContentOf(asMessage)) return [名片]; // Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted // data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so // `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix // addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns // it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that // something is there but unsupported, same spirit as the `(内容不可用)` placeholder above. if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) { return [暂不支持的消息类型]; } return
{renderDrafty(content, highlightQuery)}
; } /** * 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 ; 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 (
{formatEventTimestamp(new Date(item.ts))}
{item.title ?
{item.title}
: null} {item.summary ?
{item.summary}
: null}
); } function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) { return ( ); } 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(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.
e.stopPropagation()} >
查找聊天记录
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 ? ( ) : null}
{TABS.map((t) => ( ))}
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" />
{tab === 'text' ? (
{!debouncedQuery.trim() ? (
输入关键词搜索这个会话的聊天记录
) : searchQuery.isLoading ? (
搜索中…
) : searchQuery.isError ? (
搜索失败,请重试
) : !searchItems.length ? (
没有找到相关消息
) : ( <>
    {searchItems.map((item) => { const fullFrom = normalizeSender(item.from); const name = senderLabel(item.from); return (
  • ); })}
{searchQuery.hasNextPage ? ( searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} /> ) : null} )}
) : tab === 'image' || tab === 'video' ? (
{mediaQuery.isLoading ? (
加载中…
) : mediaQuery.isError ? (
加载失败,请重试
) : !mediaItems.length ? (
暂无内容
) : ( <>
{mediaItems.map((item) => ( ))}
{mediaQuery.hasNextPage ? ( mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} /> ) : null} )}
) : (
{fileLinkQuery.isLoading ? (
加载中…
) : fileLinkQuery.isError ? (
加载失败,请重试
) : !fileLinkItems.length ? (
暂无内容
) : ( <>
{fileLinkItems.map((item) => tab === 'link' ? ( ) : ( ), )}
{fileLinkQuery.hasNextPage ? ( fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} /> ) : null} )}
)}
); }