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