diff --git a/web/src/features/messages/DetailColumn.tsx b/web/src/features/messages/DetailColumn.tsx
index 2cca0411..37a41ec7 100644
--- a/web/src/features/messages/DetailColumn.tsx
+++ b/web/src/features/messages/DetailColumn.tsx
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
+import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import {
blockUser,
deleteFriend,
@@ -14,6 +15,7 @@ import {
updateConversationSettings,
updateFriendRemark,
} from '@/lib/api/friends';
+import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
@@ -46,6 +48,7 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
+ const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => getFriends() });
const friend = friendsQuery.data?.items.find((f) => f.user_id === topicId);
@@ -131,7 +134,29 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
-
+
+
+
+ {(
+ [
+ { type: 'image' as const, label: '图片', icon: 'image' },
+ { type: 'video' as const, label: '视频', icon: 'videocam' },
+ { type: 'file' as const, label: '文件', icon: 'description' },
+ { type: 'link' as const, label: '链接', icon: 'link' },
+ ] satisfies { type: MediaType; label: string; icon: string }[]
+ ).map((item) => (
+
+ ))}
+
+
+
+
{remarkEditing ? (
备注
@@ -195,6 +220,8 @@ export function DetailColumn({ topicId, peerName, onClose }: DetailColumnProps)
>
删除好友
+
+ {historyTab ?
setHistoryTab(null)} /> : null}
);
}
diff --git a/web/src/features/messages/HistorySearchModal.tsx b/web/src/features/messages/HistorySearchModal.tsx
new file mode 100644
index 00000000..76b9b56d
--- /dev/null
+++ b/web/src/features/messages/HistorySearchModal.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+
+import { 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 { formatEventTimestamp } from '@/lib/messages/time';
+
+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 isFileHit(item: SearchTextHit | SearchFileHit): item is SearchFileHit {
+ return 'file' in item;
+}
+
+interface HistorySearchModalProps {
+ topicId: string;
+ initialTab?: Tab;
+ onClose: () => void;
+}
+
+/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
+export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: HistorySearchModalProps) {
+ const [tab, setTab] = useState(initialTab);
+ const [query, setQuery] = useState('');
+ const debouncedQuery = useDebouncedValue(query, 300);
+
+ const searchQuery = useQuery({
+ queryKey: ['topic-search', topicId, debouncedQuery],
+ queryFn: () => searchTopicMessages(topicId, debouncedQuery, 'text'),
+ enabled: tab === 'text' && debouncedQuery.trim().length > 0,
+ });
+
+ const mediaQuery = useQuery({
+ queryKey: ['topic-media', topicId, tab],
+ queryFn: () => getTopicMedia(topicId, tab as MediaType),
+ enabled: tab !== 'text',
+ });
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+ {tab === 'text' ? (
+
+
+
+
+ setQuery(e.target.value)}
+ placeholder="搜索聊天记录"
+ className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
+ />
+
+
+
+ {!query.trim() ? (
+
输入关键词搜索这个会话的聊天记录
+ ) : searchQuery.isLoading ? (
+
搜索中…
+ ) : !searchQuery.data?.items.length ? (
+
没有找到相关消息
+ ) : (
+
+ )}
+
+
+ ) : (
+
+ {mediaQuery.isLoading ? (
+
加载中…
+ ) : !mediaQuery.data?.items.length ? (
+
暂无内容
+ ) : (
+
+ {mediaQuery.data.items.map((item) => (
+
+ t.key === tab)?.icon || 'description'} className="text-[28px] text-text-tertiary-3" />
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/web/src/lib/api/messages.ts b/web/src/lib/api/messages.ts
index fbf61f32..e071655f 100644
--- a/web/src/lib/api/messages.ts
+++ b/web/src/lib/api/messages.ts
@@ -1,7 +1,7 @@
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
// calls this then hard-deletes the message from the local topic cache itself.
-import { imPost } from './client';
+import { imGet, imPost } from './client';
export interface RecallResult {
topic: string;
@@ -24,3 +24,47 @@ export interface RecallResult {
export function recallMessage(topic: string, seq: number, version = 1, reason?: string) {
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;
+ width?: number;
+ height?: number;
+ ts: string;
+ availability: string;
+}
+
+/** README §3.4's 4-icon grid (图片/视频/文件/链接). */
+export function getTopicMedia(topic: string, type: MediaType, 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/hooks.ts b/web/src/lib/hooks.ts
new file mode 100644
index 00000000..fbbe5669
--- /dev/null
+++ b/web/src/lib/hooks.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from 'react';
+
+/** 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);
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebounced(value), delay);
+ return () => window.clearTimeout(timer);
+ }, [value, delay]);
+ return debounced;
+}