can't attach a
- * custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch
- * into a blob URL rather than a direct URL. Inline (base64 `val`) attachments skip this path entirely.
- */
-function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
- const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
-
- useEffect(() => {
- if (!ref) return undefined;
- let cancelled = false;
- let objectUrl: string | null = null;
- setState({ url: null, loading: true, error: false });
- fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() })
- .then((res) => {
- if (!res.ok) throw new Error('download failed');
- return res.blob();
- })
- .then((blob) => {
- if (cancelled) return;
- objectUrl = URL.createObjectURL(blob);
- setState({ url: objectUrl, loading: false, error: false });
- })
- .catch(() => {
- if (!cancelled) setState({ url: null, loading: false, error: true });
- });
- return () => {
- cancelled = true;
- if (objectUrl) URL.revokeObjectURL(objectUrl);
- };
- }, [ref]);
-
- return state;
-}
-
interface ImageData {
val?: string;
ref?: string;
@@ -60,7 +24,8 @@ export function ImageAttachment({ data }: { data: ImageData }) {
);
}
- return
;
+ // README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
+ return
;
}
interface FileData {
diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx
index 76b9b56d..77a44549 100644
--- a/web/src/features/messages/HistorySearchModal.tsx
+++ b/web/src/features/messages/HistorySearchModal.tsx
@@ -1,11 +1,12 @@
import { useState } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
-import { getTopicMedia, searchTopicMessages, type MediaType, type SearchFileHit, type SearchTextHit } from '@/lib/api/messages';
-import { useDebouncedValue } from '@/lib/hooks';
-import { formatFileSize } from '@/lib/messages/attachments';
+import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
+import { checkUrlSafety } from '@/lib/api/security';
+import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
+import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
type Tab = 'text' | MediaType;
@@ -18,8 +19,104 @@ const TABS: { key: Tab; label: string; icon: string }[] = [
{ key: 'link', label: '链接', icon: 'link' },
];
-function isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit {
- return 'file' in item;
+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 a real, complete Drafty document
+ * (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
+ * server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
+ * picking fields back out of it by hand. */
+function SnapshotContent({ item }: { item: SearchHit }) {
+ const content = item.snapshot?.content;
+ if (!content) return (内容不可用);
+ return (
+
+ {renderDrafty({
+ txt: content.txt || ' ',
+ fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
+ ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record })),
+ })}
+
+ );
+}
+
+/**
+ * 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 {
+ window.alert('链接安全检查失败,请稍后重试。');
+ return;
+ }
+ if (result.decision === 'block') {
+ window.alert('该链接存在安全风险,已被拦截。');
+ return;
+ }
+ if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
+ window.open(item.url, '_blank', 'noopener,noreferrer');
+ };
+
+ return (
+
+
{formatEventTimestamp(new Date(item.ts))}
+ {item.title ?
{item.title}
: null}
+
+ {item.summary ?
{item.summary}
: null}
+
+ );
}
interface HistorySearchModalProps {
@@ -42,8 +139,14 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
const mediaQuery = useQuery({
queryKey: ['topic-media', topicId, tab],
- queryFn: () => getTopicMedia(topicId, tab as MediaType),
- enabled: tab !== 'text',
+ queryFn: () => getTopicMedia(topicId, tab as MediaFetchType),
+ enabled: tab === 'image' || tab === 'video',
+ });
+
+ const fileLinkQuery = useQuery({
+ queryKey: ['topic-search-media', topicId, tab],
+ queryFn: () => searchTopicMessages(topicId, '', tab as 'file' | 'link'),
+ enabled: tab === 'file' || tab === 'link',
});
return (
@@ -100,8 +203,8 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
{formatEventTimestamp(new Date(item.ts))}
-
- {isFileHit(item) ? `[文件] ${item.file.name} · ${formatFileSize(item.file.size)}` : item.snippet}
+
+
))}
@@ -109,22 +212,34 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
)}
- ) : (
+ ) : tab === 'image' || tab === 'video' ? (
{mediaQuery.isLoading ? (
加载中…
) : !mediaQuery.data?.items.length ? (
暂无内容
) : (
-
+
{mediaQuery.data.items.map((item) => (
-
- t.key === tab)?.icon || 'description'} className="text-[28px] text-text-tertiary-3" />
-
+
))}
)}
+ ) : (
+
+ {fileLinkQuery.isLoading ? (
+
加载中…
+ ) : !fileLinkQuery.data?.items.length ? (
+
暂无内容
+ ) : (
+
+ {fileLinkQuery.data.items.map((item) =>
+ tab === 'link' ? : ,
+ )}
+
+ )}
+
)}
diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts
index 3aa58789..59977671 100644
--- a/web/src/lib/api/messages.ts
+++ b/web/src/lib/api/messages.ts
@@ -25,45 +25,76 @@ export function recallMessage(topic: string, seq: number, version = 1, reason?:
return imPost(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
}
-export interface SearchTextHit {
- seq: number;
- from: string;
- ts: string;
- snippet: string;
- matches?: Array<{ start: number; end: number }>;
-}
-
-export interface SearchFileHit {
- seq: number;
- from: string;
- ts: string;
- file: { file_id: string; name: string; size: number; mime: string };
- availability: string;
-}
-
-/** README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1251-1252. */
-export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' = 'text', cursor = '') {
- const qs = new URLSearchParams({ q: query, type });
- if (cursor) qs.set('cursor', cursor);
- return imGet<{ topic: string; items: Array; next_cursor?: string }>(
- `/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`,
- );
-}
-
-export type MediaType = 'image' | 'video' | 'file' | 'link';
-
-export interface MediaItem {
- seq: number;
- thumbnail_file_id?: string;
- file_id?: string;
+export interface DraftyEntityData {
+ ref?: string;
+ val?: string;
+ mime?: string;
+ name?: string;
+ size?: number;
width?: number;
height?: number;
- ts: string;
- availability: string;
+ duration?: number;
}
-/** README §3.4's 4-icon grid (图片/视频/文件/链接). */
-export function getTopicMedia(topic: string, type: MediaType, cursor = '') {
+export interface DraftyEntity {
+ tp?: string;
+ data?: DraftyEntityData;
+}
+
+/**
+ * docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` /
+ * `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload
+ * wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}`
+ * Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it)
+ * around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON-
+ * stringified fallback the server fills in for non-text messages -- do not render it.
+ */
+export interface SearchHit {
+ seq: number;
+ from: string;
+ ts: string;
+ availability?: string;
+ matches?: Array<{ start: number; end: number }>;
+ snippet?: string;
+ snapshot?: {
+ content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] };
+ };
+}
+
+/**
+ * README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898.
+ * `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per
+ * §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`.
+ */
+export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') {
+ const qs = new URLSearchParams({ q: query, type });
+ if (cursor) qs.set('cursor', cursor);
+ return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`);
+}
+
+/** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is
+ * selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link
+ * go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */
+export type MediaType = 'image' | 'video' | 'file' | 'link';
+
+/** The subset of MediaType that `/media` itself understands. */
+export type MediaFetchType = 'image' | 'video';
+
+/**
+ * docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts,
+ * availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is
+ * the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the
+ * actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else.
+ */
+export interface MediaItem {
+ seq: number;
+ from?: string;
+ ts: string;
+ availability: string;
+ ent?: DraftyEntity[];
+}
+
+export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') {
const qs = new URLSearchParams({ type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
diff --git a/web/src/lib/api/security.ts b/web/src/lib/api/security.ts
new file mode 100644
index 00000000..63f7d412
--- /dev/null
+++ b/web/src/lib/api/security.ts
@@ -0,0 +1,17 @@
+// docs/IM-前后端对接文档.md:1822,1898,1943 -- links surfaced anywhere in the UI (favorites, history
+// search) must go through this check before being opened; the server, not the client, holds the
+// risk classification (it fetches/caches the URL server-side to avoid the client ever hitting an
+// attacker-controlled address directly, which is also why there's no client-side allow/block list).
+import { imPost } from './client';
+
+export interface UrlSafetyResult {
+ url: string;
+ decision: 'allow' | 'warn' | 'block';
+ risk_level: string;
+ reason?: string;
+ risk_challenge_id?: string;
+}
+
+export function checkUrlSafety(url: string, source: string) {
+ return imPost('/security/urls/check', { url, source });
+}
diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts
index 14724857..01ff3d98 100644
--- a/web/src/lib/hooks.ts
+++ b/web/src/lib/hooks.ts
@@ -1,5 +1,44 @@
import { useEffect, useState } from 'react';
+import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
+
+/**
+ * GET /v0/file/s/{file_id} requires the same auth header as upload (see upload.ts's fileAuthHeaders
+ * for exactly which one, and why) -- a plain
or can't attach a
+ * custom header, so an externally-referenced (non-inline) attachment needs an authenticated fetch
+ * into a blob URL rather than a direct URL. `ref` is anything fileDownloadUrl accepts: a full
+ * "/v0/file/s/xxx" path (Drafty `refurl`), or an absolute URL.
+ */
+export function useAuthedBlobUrl(ref: string | undefined): { url: string | null; loading: boolean; error: boolean } {
+ const [state, setState] = useState<{ url: string | null; loading: boolean; error: boolean }>({ url: null, loading: !!ref, error: false });
+
+ useEffect(() => {
+ if (!ref) return undefined;
+ let cancelled = false;
+ let objectUrl: string | null = null;
+ setState({ url: null, loading: true, error: false });
+ fetch(fileDownloadUrl(ref), { headers: fileAuthHeaders() })
+ .then((res) => {
+ if (!res.ok) throw new Error('download failed');
+ return res.blob();
+ })
+ .then((blob) => {
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setState({ url: objectUrl, loading: false, error: false });
+ })
+ .catch(() => {
+ if (!cancelled) setState({ url: null, loading: false, error: true });
+ });
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [ref]);
+
+ return state;
+}
+
/** Ported from the old app's use-cached-data.js -- debounces a fast-changing value (e.g. search input). */
export function useDebouncedValue(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);