);
}
/** 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 (内容不可用);
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 [名片];
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.