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;
|
||||
keyword: string;
|
||||
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(() => {
|
||||
if (!text || !keyword) return [{ text, highlight: false }];
|
||||
|
||||
@@ -37,12 +38,10 @@ const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) =
|
||||
}, [text, keyword]);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text style={style}>
|
||||
{parts.map((part, i) =>
|
||||
part.highlight ? (
|
||||
<Text key={i} style={style}>
|
||||
{part.text}
|
||||
</Text>
|
||||
<Text key={i} style={highlightStyle}>{part.text}</Text>
|
||||
) : (
|
||||
<Text key={i}>{part.text}</Text>
|
||||
)
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -17,6 +18,7 @@ export interface IMessageRepository {
|
||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
||||
clearConversation(conversationId: string): Promise<void>;
|
||||
search(keyword: string): Promise<CachedMessage[]>;
|
||||
searchByConversation(conversationId: string, keyword: string, limit?: number, offset?: number): Promise<CachedMessage[]>;
|
||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
||||
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 }));
|
||||
}
|
||||
|
||||
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 }> {
|
||||
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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -837,6 +837,19 @@ const GroupInfoScreen: React.FC = () => {
|
||||
thumbColor={isNotificationMuted ? colors.primary.main : colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
<Divider style={styles.settingDivider} />
|
||||
{renderSettingItem(
|
||||
'magnify',
|
||||
'查找聊天记录',
|
||||
undefined,
|
||||
conversationId
|
||||
? () => router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: group?.name || '群聊',
|
||||
isGroupChat: true,
|
||||
}))
|
||||
: undefined,
|
||||
)}
|
||||
</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 = () => {
|
||||
// TODO: 实现聊天记录搜索功能
|
||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
||||
router.push(hrefs.hrefMessageSearch({
|
||||
conversationId,
|
||||
conversationName: userName || user?.nickname || '私聊',
|
||||
isGroupChat: false,
|
||||
}));
|
||||
};
|
||||
|
||||
// 查看用户资料
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ChatRouteParams {
|
||||
isGroupChat?: boolean;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
scrollToSeq?: number;
|
||||
}
|
||||
|
||||
// 发送者信息
|
||||
|
||||
@@ -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<string | null>(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<number | null>(null);
|
||||
|
||||
// 回复消息状态
|
||||
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user