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 ? ( +
没有找到相关消息
+ ) : ( +
    + {searchQuery.data.items.map((item) => ( +
  • +
    + {formatEventTimestamp(new Date(item.ts))} +
    +
    + {isFileHit(item) ? `[文件] ${item.file.name} · ${formatFileSize(item.file.size)}` : item.snippet} +
    +
  • + ))} +
+ )} +
+
+ ) : ( +
+ {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; +}