From d8d2b03f9467788ab233fe5fd4532c2b70543dca Mon Sep 17 00:00:00 2001 From: lan Date: Thu, 14 May 2026 02:26:32 +0800 Subject: [PATCH] feat(message): implement message search functionality Add ability to search messages within a specific conversation. This includes: - New `MessageSearchScreen` for displaying search results. - `MessageRepository.searchByConversation` to query messages by keyword and conversation ID. - Integration of search entry points in `GroupInfoScreen` and `PrivateChatInfoScreen`. - Support for scrolling to a specific message sequence (`scrollToSeq`) when navigating from search results. - Enhanced `HighlightText` component to support custom highlight styles. feat(message): implement message search functionality --- app/(app)/chat/message-search.tsx | 5 + src/components/common/HighlightText.tsx | 9 +- .../repositories/MessageRepository.ts | 31 ++ src/navigation/hrefs.ts | 17 +- src/screens/message/GroupInfoScreen.tsx | 13 + src/screens/message/MessageSearchScreen.tsx | 347 ++++++++++++++++++ src/screens/message/PrivateChatInfoScreen.tsx | 7 +- .../message/components/ChatScreen/types.ts | 1 + .../components/ChatScreen/useChatScreen.ts | 41 ++- src/screens/message/index.ts | 1 + 10 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 app/(app)/chat/message-search.tsx create mode 100644 src/screens/message/MessageSearchScreen.tsx diff --git a/app/(app)/chat/message-search.tsx b/app/(app)/chat/message-search.tsx new file mode 100644 index 0000000..ba5b7f2 --- /dev/null +++ b/app/(app)/chat/message-search.tsx @@ -0,0 +1,5 @@ +import { MessageSearchScreen } from '../../../src/screens/message'; + +export default function MessageSearchRoute() { + return ; +} \ No newline at end of file diff --git a/src/components/common/HighlightText.tsx b/src/components/common/HighlightText.tsx index 21263ce..1979227 100644 --- a/src/components/common/HighlightText.tsx +++ b/src/components/common/HighlightText.tsx @@ -5,9 +5,10 @@ interface HighlightTextProps { text: string; keyword: string; style?: StyleProp; + highlightStyle?: StyleProp; } -const HighlightText: React.FC = ({ text, keyword, style }) => { +const HighlightText: React.FC = ({ text, keyword, style, highlightStyle }) => { const parts = useMemo(() => { if (!text || !keyword) return [{ text, highlight: false }]; @@ -37,12 +38,10 @@ const HighlightText: React.FC = ({ text, keyword, style }) = }, [text, keyword]); return ( - + {parts.map((part, i) => part.highlight ? ( - - {part.text} - + {part.text} ) : ( {part.text} ) diff --git a/src/database/repositories/MessageRepository.ts b/src/database/repositories/MessageRepository.ts index f64384c..dd4b2a7 100644 --- a/src/database/repositories/MessageRepository.ts +++ b/src/database/repositories/MessageRepository.ts @@ -1,6 +1,7 @@ import { localDataSource } from '../LocalDataSource'; import { CachedMessage } from '../types'; import type { ILocalDataSource } from '@/data/datasources/interfaces'; +import { extractTextFromSegments } from '@/types/dto'; export interface IMessageRepository { saveMessage(message: CachedMessage): Promise; @@ -17,6 +18,7 @@ export interface IMessageRepository { updateStatus(messageId: string, status: string, clearContent?: boolean): Promise; clearConversation(conversationId: string): Promise; search(keyword: string): Promise; + searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise; getStats(): Promise<{ totalMessages: number; totalConversations: number }>; getLastMessageByConversation(conversationId: string): Promise; } @@ -143,6 +145,35 @@ export class MessageRepository implements IMessageRepository { return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })); } + async searchByConversation( + conversationId: string, + keyword: string, + limit: number = 50, + offset: number = 0 + ): Promise { + const likeParam = `%${keyword}%`; + const rows = await this.dataSource.query( + `SELECT * FROM messages + WHERE conversationId = ? + AND status != 'deleted' + AND (segments LIKE ? OR content LIKE ?) + ORDER BY createdAt DESC + LIMIT ? OFFSET ?`, + [conversationId, likeParam, likeParam, limit, offset] + ); + const lowerKeyword = keyword.toLowerCase(); + return rows + .map(r => ({ + ...r, + isRead: r.isRead === 1, + segments: r.segments ? JSON.parse(r.segments) : undefined, + })) + .filter(msg => { + const text = extractTextFromSegments(msg.segments).toLowerCase(); + return text.includes(lowerKeyword); + }); + } + async getStats(): Promise<{ totalMessages: number; totalConversations: number }> { const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`); const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`); diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index dae9ef8..1a11461 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -87,14 +87,16 @@ export function hrefChat(params: { groupId?: string; groupName?: string; groupAvatar?: string; + scrollToSeq?: number; }): string { - const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params; + const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar, scrollToSeq } = params; const q = new URLSearchParams(); if (userId) q.set('userId', userId); if (isGroupChat) q.set('isGroupChat', '1'); if (groupId != null && groupId !== '') q.set('groupId', String(groupId)); if (groupName) q.set('groupName', groupName); if (groupAvatar) q.set('groupAvatar', groupAvatar); + if (scrollToSeq != null) q.set('scrollToSeq', String(scrollToSeq)); const qs = q.toString(); return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`; } @@ -131,6 +133,19 @@ export function hrefGroupInviteDetail(message: SystemMessageResponse): string { return `/group/invite?messageId=${encodeURIComponent(message.id)}`; } +export function hrefMessageSearch(params: { + conversationId: string; + conversationName?: string; + isGroupChat?: boolean; +}): string { + const q = new URLSearchParams({ + conversationId: params.conversationId, + }); + if (params.conversationName) q.set('conversationName', params.conversationName); + if (params.isGroupChat) q.set('isGroupChat', '1'); + return `/chat/message-search?${q.toString()}`; +} + export function hrefPrivateChatInfo(params: { conversationId: string; userId: string; diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index f274ca7..444e4d9 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -837,6 +837,19 @@ const GroupInfoScreen: React.FC = () => { thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper} /> + + {renderSettingItem( + 'magnify', + '查找聊天记录', + undefined, + conversationId + ? () => router.push(hrefs.hrefMessageSearch({ + conversationId, + conversationName: group?.name || '群聊', + isGroupChat: true, + })) + : undefined, + )} diff --git a/src/screens/message/MessageSearchScreen.tsx b/src/screens/message/MessageSearchScreen.tsx new file mode 100644 index 0000000..6303244 --- /dev/null +++ b/src/screens/message/MessageSearchScreen.tsx @@ -0,0 +1,347 @@ +/** + * MessageSearchScreen 聊天记录搜索页 + * 在单个会话中搜索消息记录,基于本地 SQLite 缓存 + */ + +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import { FlashList } from '@shopify/flash-list'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { + spacing, + fontSizes, + borderRadius, + useAppColors, + type AppColors, +} from '../../theme'; +import { CachedMessage } from '../../database/types'; +import { extractTextFromSegments, UserDTO } from '../../types/dto'; +import { messageRepository } from '../../database'; +import { userManager } from '../../stores/user'; +import { useAuthStore } from '../../stores'; +import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common'; +import { SearchBar } from '../../components/business'; +import HighlightText from '../../components/common/HighlightText'; +import { formatTime } from '../../utils/formatTime'; +import * as hrefs from '../../navigation/hrefs'; + +const PAGE_SIZE = 50; + +interface SenderInfo { + nickname: string; + avatar?: string; +} + +export const MessageSearchScreen: React.FC = () => { + const colors = useAppColors(); + const styles = useMemo(() => createMessageSearchStyles(colors), [colors]); + const router = useRouter(); + const currentUserId = useAuthStore(state => state.currentUser?.id); + + const raw = useLocalSearchParams<{ + conversationId?: string; + conversationName?: string; + isGroupChat?: string; + }>(); + const conversationId = raw.conversationId ?? ''; + const conversationName = raw.conversationName ?? '聊天'; + const isGroupChat = raw.isGroupChat === '1'; + + const [keyword, setKeyword] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [searched, setSearched] = useState(false); + const senderCache = useRef>(new Map()); + const debounceRef = useRef | null>(null); + + const doSearch = useCallback(async (text: string, offset = 0) => { + if (!text.trim() || !conversationId) return; + setLoading(true); + try { + const msgs = await messageRepository.searchByConversation( + conversationId, + text.trim(), + PAGE_SIZE, + offset, + ); + if (offset === 0) { + setResults(msgs); + } else { + setResults(prev => [...prev, ...msgs]); + } + setHasMore(msgs.length >= PAGE_SIZE); + setSearched(true); + + // prefetch sender info (skip system sender 10000) + const senderIds = new Set(msgs.map(m => m.senderId).filter(id => id !== '10000')); + for (const sid of senderIds) { + if (!senderCache.current.has(sid)) { + try { + const u = await userManager.getUserById(sid); + if (u) { + senderCache.current.set(sid, { + nickname: u.nickname || u.username || sid, + avatar: u.avatar, + }); + } + } catch { + senderCache.current.set(sid, { nickname: sid }); + } + } + } + // system sender cache entry + senderCache.current.set('10000', { nickname: '系统通知' }); + } catch (e) { + console.error('搜索消息失败:', e); + } finally { + setLoading(false); + } + }, [conversationId]); + + const handleTextChanged = useCallback((text: string) => { + setKeyword(text); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!text.trim()) { + setResults([]); + setSearched(false); + setHasMore(false); + return; + } + debounceRef.current = setTimeout(() => { + doSearch(text, 0); + }, 300); + }, [doSearch]); + + const handleSubmit = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (keyword.trim()) { + doSearch(keyword, 0); + } + }, [keyword, doSearch]); + + const handleEndReached = useCallback(() => { + if (!loading && hasMore && keyword.trim()) { + doSearch(keyword, results.length); + } + }, [loading, hasMore, keyword, results.length, doSearch]); + + const handlePressResult = useCallback((msg: CachedMessage) => { + router.push( + hrefs.hrefChat({ + conversationId, + isGroupChat, + scrollToSeq: msg.seq, + }) as any, + ); + }, [router, conversationId, isGroupChat]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const getSender = useCallback((senderId: string): SenderInfo => { + if (senderCache.current.has(senderId)) { + return senderCache.current.get(senderId)!; + } + return { nickname: senderId }; + }, []); + + const renderItem = useCallback(({ item }: { item: CachedMessage }) => { + const sender = getSender(item.senderId); + const isMe = item.senderId === currentUserId; + const displayText = extractTextFromSegments(item.segments) || item.content || ''; + return ( + handlePressResult(item)} + activeOpacity={0.6} + > + + + + + {isMe ? '我' : sender.nickname} + + + {formatTime(item.createdAt)} + + + + + + ); + }, [styles, keyword, currentUserId, getSender, handlePressResult]); + + const ListEmptyComponent = useMemo(() => { + if (loading) { + return ( + + + + ); + } + if (!searched) { + return ( + + + + ); + } + return ( + + + + ); + }, [loading, searched, styles, colors]); + + const ListFooterComponent = useMemo(() => { + if (loading && results.length > 0) { + return ( + + + + ); + } + return null; + }, [loading, results.length, styles, colors]); + + return ( + + + router.back()} /> + + {conversationName} + + + + + + + + + item.id} + onEndReached={handleEndReached} + onEndReachedThreshold={0.3} + ListEmptyComponent={ListEmptyComponent} + ListFooterComponent={ListFooterComponent} + keyboardShouldPersistTaps="handled" + /> + + ); +}; + +function createMessageSearchStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + }, + headerTitle: { + flex: 1, + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + textAlign: 'center', + marginHorizontal: spacing.sm, + }, + headerSpacer: { + width: 40, + }, + searchWrap: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + }, + resultItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + resultContent: { + flex: 1, + marginLeft: spacing.md, + }, + resultHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 2, + }, + senderName: { + fontSize: fontSizes.sm, + fontWeight: '500', + color: colors.text.primary, + flex: 1, + marginRight: spacing.sm, + }, + resultTime: { + fontSize: fontSizes.xs, + color: colors.text.hint, + }, + resultText: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + lineHeight: fontSizes.sm * 1.4, + }, + highlightText: { + color: colors.primary.main, + fontWeight: '600', + }, + emptyContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingTop: spacing.xl * 3, + }, + footerLoader: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + }); +} diff --git a/src/screens/message/PrivateChatInfoScreen.tsx b/src/screens/message/PrivateChatInfoScreen.tsx index 04a912f..b0f2b97 100644 --- a/src/screens/message/PrivateChatInfoScreen.tsx +++ b/src/screens/message/PrivateChatInfoScreen.tsx @@ -145,8 +145,11 @@ const PrivateChatInfoScreen: React.FC = () => { // 查找聊天记录 const handleSearchMessages = () => { - // TODO: 实现聊天记录搜索功能 - Alert.alert('提示', '聊天记录搜索功能开发中'); + router.push(hrefs.hrefMessageSearch({ + conversationId, + conversationName: userName || user?.nickname || '私聊', + isGroupChat: false, + })); }; // 查看用户资料 diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 1c38f15..403570d 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -33,6 +33,7 @@ export interface ChatRouteParams { isGroupChat?: boolean; groupId?: string; groupName?: string; + scrollToSeq?: number; } // 发送者信息 diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 3266caf..c70e518 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -97,6 +97,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { groupId?: string | string[]; groupName?: string | string[]; groupAvatar?: string | string[]; + scrollToSeq?: string | string[]; }>(); // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 // 嵌入式模式下优先使用 props,否则从路由参数获取 @@ -112,6 +113,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { }; } const isGroupFlag = firstRouteParam(rawParams.isGroupChat); + const scrollToSeqParam = firstRouteParam(rawParams.scrollToSeq); return { conversationId: firstRouteParam(rawParams.conversationId) ?? null, userId: firstRouteParam(rawParams.userId) ?? null, @@ -119,6 +121,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { groupId: firstRouteParam(rawParams.groupId), groupName: firstRouteParam(rawParams.groupName), groupAvatar: firstRouteParam(rawParams.groupAvatar), + scrollToSeq: scrollToSeqParam ? Number(scrollToSeqParam) : null, }; }, [ props?.embeddedConversationId, @@ -131,9 +134,10 @@ export const useChatScreen = (props?: ChatScreenProps) => { rawParams.groupId, rawParams.groupName, rawParams.groupAvatar, + rawParams.scrollToSeq, ]); - const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar } = routeParams; + const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName, groupAvatar: routeGroupAvatar, scrollToSeq: routeScrollToSeq } = routeParams; // 当前会话ID(可能在新建私聊会话后动态更新) const [conversationId, setConversationId] = useState(routeConversationId); @@ -200,6 +204,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { const isBrowsingHistoryRef = useRef(false); const hasShownMessageListRef = useRef(false); const historyLoadingLockUntilRef = useRef(0); + const scrollToSeqRef = useRef(null); // 回复消息状态 const [replyingTo, setReplyingTo] = useState(null); @@ -377,6 +382,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { isBrowsingHistoryRef.current = false; hasShownMessageListRef.current = false; historyLoadingLockUntilRef.current = 0; + scrollToSeqRef.current = routeScrollToSeq ?? null; }, [conversationId]); useEffect(() => { @@ -415,7 +421,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { return scrollY <= 100; }, [scrollPositionRef]); - // 首屏加载完成后仅锚定一次到最新消息端 + // 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过) useEffect(() => { if ( loading || @@ -427,6 +433,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { return; } hasInitialAnchorDoneRef.current = true; + if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理 const timer = setTimeout(() => { scrollToLatest(false, true, 'initial-anchor'); }, 0); @@ -606,6 +613,36 @@ export const useChatScreen = (props?: ChatScreenProps) => { } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); + // 从搜索结果跳转:滚动到目标 seq + useEffect(() => { + const targetSeq = scrollToSeqRef.current; + if (targetSeq == null || !hasInitialAnchorDoneRef.current) return; + if (loading || loadingMore) return; + + const found = messages.find(m => m.seq === targetSeq); + if (found) { + scrollToSeqRef.current = null; + isBrowsingHistoryRef.current = true; + suppressAutoFollowRef.current = true; + const ascendingIndex = messages.findIndex(m => m.seq === targetSeq); + const displayIndex = messages.length - 1 - ascendingIndex; + setTimeout(() => { + flatListRef.current?.scrollToIndex({ + index: displayIndex, + animated: false, + viewPosition: 0.5, + }); + }, 50); + return; + } + + if (hasMoreHistory) { + loadMoreHistory(); + } else { + scrollToSeqRef.current = null; + } + }, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]); + // 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理 const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { scrollPositionRef.current.contentHeight = contentHeight; diff --git a/src/screens/message/index.ts b/src/screens/message/index.ts index b73b845..4844cd1 100644 --- a/src/screens/message/index.ts +++ b/src/screens/message/index.ts @@ -12,3 +12,4 @@ export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen'; export { default as JoinGroupScreen } from './JoinGroupScreen'; export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen'; export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen'; +export { MessageSearchScreen } from './MessageSearchScreen';