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
This commit is contained in:
5
app/(app)/chat/message-search.tsx
Normal file
5
app/(app)/chat/message-search.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MessageSearchScreen } from '../../../src/screens/message';
|
||||||
|
|
||||||
|
export default function MessageSearchRoute() {
|
||||||
|
return <MessageSearchScreen />;
|
||||||
|
}
|
||||||
@@ -5,9 +5,10 @@ interface HighlightTextProps {
|
|||||||
text: string;
|
text: string;
|
||||||
keyword: string;
|
keyword: string;
|
||||||
style?: StyleProp<TextStyle>;
|
style?: StyleProp<TextStyle>;
|
||||||
|
highlightStyle?: StyleProp<TextStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
|
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style, highlightStyle }) => {
|
||||||
const parts = useMemo(() => {
|
const parts = useMemo(() => {
|
||||||
if (!text || !keyword) return [{ text, highlight: false }];
|
if (!text || !keyword) return [{ text, highlight: false }];
|
||||||
|
|
||||||
@@ -37,12 +38,10 @@ const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) =
|
|||||||
}, [text, keyword]);
|
}, [text, keyword]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text style={style}>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
part.highlight ? (
|
part.highlight ? (
|
||||||
<Text key={i} style={style}>
|
<Text key={i} style={highlightStyle}>{part.text}</Text>
|
||||||
{part.text}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Text key={i}>{part.text}</Text>
|
<Text key={i}>{part.text}</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
import { localDataSource } from '../LocalDataSource';
|
||||||
import { CachedMessage } from '../types';
|
import { CachedMessage } from '../types';
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
||||||
|
import { extractTextFromSegments } from '@/types/dto';
|
||||||
|
|
||||||
export interface IMessageRepository {
|
export interface IMessageRepository {
|
||||||
saveMessage(message: CachedMessage): Promise<void>;
|
saveMessage(message: CachedMessage): Promise<void>;
|
||||||
@@ -17,6 +18,7 @@ export interface IMessageRepository {
|
|||||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||||
clearConversation(conversationId: string): Promise<void>;
|
clearConversation(conversationId: string): Promise<void>;
|
||||||
search(keyword: string): Promise<CachedMessage[]>;
|
search(keyword: string): Promise<CachedMessage[]>;
|
||||||
|
searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise<CachedMessage[]>;
|
||||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
||||||
getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>;
|
getLastMessageByConversation(conversationId: string): Promise<CachedMessage | null>;
|
||||||
}
|
}
|
||||||
@@ -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 }));
|
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<CachedMessage[]> {
|
||||||
|
const likeParam = `%${keyword}%`;
|
||||||
|
const rows = await this.dataSource.query<any>(
|
||||||
|
`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 }> {
|
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
|
||||||
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
|
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`);
|
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
|
||||||
|
|||||||
@@ -87,14 +87,16 @@ export function hrefChat(params: {
|
|||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
groupAvatar?: string;
|
groupAvatar?: string;
|
||||||
|
scrollToSeq?: number;
|
||||||
}): string {
|
}): string {
|
||||||
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar } = params;
|
const { conversationId, userId, isGroupChat, groupId, groupName, groupAvatar, scrollToSeq } = params;
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (userId) q.set('userId', userId);
|
if (userId) q.set('userId', userId);
|
||||||
if (isGroupChat) q.set('isGroupChat', '1');
|
if (isGroupChat) q.set('isGroupChat', '1');
|
||||||
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
|
if (groupId != null && groupId !== '') q.set('groupId', String(groupId));
|
||||||
if (groupName) q.set('groupName', groupName);
|
if (groupName) q.set('groupName', groupName);
|
||||||
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
||||||
|
if (scrollToSeq != null) q.set('scrollToSeq', String(scrollToSeq));
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
|
return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,19 @@ export function hrefGroupInviteDetail(message: SystemMessageResponse): string {
|
|||||||
return `/group/invite?messageId=${encodeURIComponent(message.id)}`;
|
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: {
|
export function hrefPrivateChatInfo(params: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -837,6 +837,19 @@ const GroupInfoScreen: React.FC = () => {
|
|||||||
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<Divider style={styles.settingDivider} />
|
||||||
|
{renderSettingItem(
|
||||||
|
'magnify',
|
||||||
|
'查找聊天记录',
|
||||||
|
undefined,
|
||||||
|
conversationId
|
||||||
|
? () => router.push(hrefs.hrefMessageSearch({
|
||||||
|
conversationId,
|
||||||
|
conversationName: group?.name || '群聊',
|
||||||
|
isGroupChat: true,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
347
src/screens/message/MessageSearchScreen.tsx
Normal file
347
src/screens/message/MessageSearchScreen.tsx
Normal file
@@ -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<CachedMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const senderCache = useRef<Map<string, SenderInfo>>(new Map());
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.resultItem}
|
||||||
|
onPress={() => handlePressResult(item)}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
source={sender.avatar ? { uri: sender.avatar } : undefined}
|
||||||
|
name={sender.nickname}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<View style={styles.resultContent}>
|
||||||
|
<View style={styles.resultHeader}>
|
||||||
|
<Text style={styles.senderName} numberOfLines={1}>
|
||||||
|
{isMe ? '我' : sender.nickname}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.resultTime}>
|
||||||
|
{formatTime(item.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<HighlightText
|
||||||
|
text={displayText}
|
||||||
|
keyword={keyword}
|
||||||
|
style={styles.resultText}
|
||||||
|
highlightStyle={styles.highlightText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}, [styles, keyword, currentUserId, getSender, handlePressResult]);
|
||||||
|
|
||||||
|
const ListEmptyComponent = useMemo(() => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!searched) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<EmptyState
|
||||||
|
icon="magnify"
|
||||||
|
title="搜索聊天记录"
|
||||||
|
description="输入关键词搜索聊天记录"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<EmptyState
|
||||||
|
icon="message-off-outline"
|
||||||
|
title="未找到相关消息"
|
||||||
|
description="换个关键词试试"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [loading, searched, styles, colors]);
|
||||||
|
|
||||||
|
const ListFooterComponent = useMemo(() => {
|
||||||
|
if (loading && results.length > 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.footerLoader}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [loading, results.length, styles, colors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<AppBackButton onPress={() => router.back()} />
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{conversationName}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.searchWrap}>
|
||||||
|
<SearchBar
|
||||||
|
value={keyword}
|
||||||
|
onChangeText={handleTextChanged}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder="搜索聊天记录"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={results}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
ListEmptyComponent={ListEmptyComponent}
|
||||||
|
ListFooterComponent={ListFooterComponent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -145,8 +145,11 @@ const PrivateChatInfoScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 查找聊天记录
|
// 查找聊天记录
|
||||||
const handleSearchMessages = () => {
|
const handleSearchMessages = () => {
|
||||||
// TODO: 实现聊天记录搜索功能
|
router.push(hrefs.hrefMessageSearch({
|
||||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
conversationId,
|
||||||
|
conversationName: userName || user?.nickname || '私聊',
|
||||||
|
isGroupChat: false,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查看用户资料
|
// 查看用户资料
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface ChatRouteParams {
|
|||||||
isGroupChat?: boolean;
|
isGroupChat?: boolean;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
|
scrollToSeq?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送者信息
|
// 发送者信息
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
groupId?: string | string[];
|
groupId?: string | string[];
|
||||||
groupName?: string | string[];
|
groupName?: string | string[];
|
||||||
groupAvatar?: string | string[];
|
groupAvatar?: string | string[];
|
||||||
|
scrollToSeq?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||||
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
||||||
@@ -112,6 +113,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||||
|
const scrollToSeqParam = firstRouteParam(rawParams.scrollToSeq);
|
||||||
return {
|
return {
|
||||||
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
||||||
userId: firstRouteParam(rawParams.userId) ?? null,
|
userId: firstRouteParam(rawParams.userId) ?? null,
|
||||||
@@ -119,6 +121,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
groupId: firstRouteParam(rawParams.groupId),
|
groupId: firstRouteParam(rawParams.groupId),
|
||||||
groupName: firstRouteParam(rawParams.groupName),
|
groupName: firstRouteParam(rawParams.groupName),
|
||||||
groupAvatar: firstRouteParam(rawParams.groupAvatar),
|
groupAvatar: firstRouteParam(rawParams.groupAvatar),
|
||||||
|
scrollToSeq: scrollToSeqParam ? Number(scrollToSeqParam) : null,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
props?.embeddedConversationId,
|
props?.embeddedConversationId,
|
||||||
@@ -131,9 +134,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
rawParams.groupId,
|
rawParams.groupId,
|
||||||
rawParams.groupName,
|
rawParams.groupName,
|
||||||
rawParams.groupAvatar,
|
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(可能在新建私聊会话后动态更新)
|
// 当前会话ID(可能在新建私聊会话后动态更新)
|
||||||
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
const [conversationId, setConversationId] = useState<string | null>(routeConversationId);
|
||||||
@@ -200,6 +204,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const isBrowsingHistoryRef = useRef(false);
|
const isBrowsingHistoryRef = useRef(false);
|
||||||
const hasShownMessageListRef = useRef(false);
|
const hasShownMessageListRef = useRef(false);
|
||||||
const historyLoadingLockUntilRef = useRef(0);
|
const historyLoadingLockUntilRef = useRef(0);
|
||||||
|
const scrollToSeqRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 回复消息状态
|
// 回复消息状态
|
||||||
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
||||||
@@ -377,6 +382,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isBrowsingHistoryRef.current = false;
|
isBrowsingHistoryRef.current = false;
|
||||||
hasShownMessageListRef.current = false;
|
hasShownMessageListRef.current = false;
|
||||||
historyLoadingLockUntilRef.current = 0;
|
historyLoadingLockUntilRef.current = 0;
|
||||||
|
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -415,7 +421,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
return scrollY <= 100;
|
return scrollY <= 100;
|
||||||
}, [scrollPositionRef]);
|
}, [scrollPositionRef]);
|
||||||
|
|
||||||
// 首屏加载完成后仅锚定一次到最新消息端
|
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
loading ||
|
loading ||
|
||||||
@@ -427,6 +433,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasInitialAnchorDoneRef.current = true;
|
hasInitialAnchorDoneRef.current = true;
|
||||||
|
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
scrollToLatest(false, true, 'initial-anchor');
|
scrollToLatest(false, true, 'initial-anchor');
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -606,6 +613,36 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
}, [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 统一处理
|
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
|
||||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||||
scrollPositionRef.current.contentHeight = contentHeight;
|
scrollPositionRef.current.contentHeight = contentHeight;
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen';
|
|||||||
export { default as JoinGroupScreen } from './JoinGroupScreen';
|
export { default as JoinGroupScreen } from './JoinGroupScreen';
|
||||||
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
|
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
|
||||||
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
|
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
|
||||||
|
export { MessageSearchScreen } from './MessageSearchScreen';
|
||||||
|
|||||||
Reference in New Issue
Block a user