feat(search): add keyword highlighting to PostCard and improve QRCodeScanner
Add HighlightText component for search result highlighting in PostCard. Refactor QRCodeScanner to use async permission methods with better error handling. Improve SearchScreen with stable function refs and pass highlightKeyword to PostCard. Modernize MessageListScreen search UI with SearchBar and TabBar components. Add emoji virtualization to EmojiPanel and auto-focus input after inserting emoji. BREAKING CHANGE: EmojiPanel onFocusInput prop added for focus management
This commit is contained in:
@@ -18,6 +18,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import HighlightText from '../../common/HighlightText';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
@@ -84,6 +85,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (prev.isAuthor !== next.isAuthor) return false;
|
||||
if (prev.style !== next.style) return false;
|
||||
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
||||
if (prev.highlightKeyword !== next.highlightKeyword) return false;
|
||||
|
||||
const a = prev.post;
|
||||
const b = next.post;
|
||||
@@ -374,6 +376,10 @@ function createPostCardStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: `${colors.warning.main}40`,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +397,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
isAuthor = false,
|
||||
index,
|
||||
style,
|
||||
highlightKeyword,
|
||||
} = normalizedProps;
|
||||
|
||||
const colors = useAppColors();
|
||||
@@ -556,7 +563,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
) : (
|
||||
<View style={styles.gridNoImagePreview}>
|
||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||
{contentPreview}
|
||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -570,7 +577,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -636,7 +643,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -646,7 +653,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{content}
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
|
||||
@@ -118,6 +118,9 @@ export interface PostCardProps {
|
||||
/** 索引(用于虚拟化列表优化) */
|
||||
index?: number;
|
||||
|
||||
/** 搜索高亮关键词 */
|
||||
highlightKeyword?: string;
|
||||
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
@@ -195,18 +195,23 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
||||
|
||||
// 动态导入 expo-camera,避免 Web 端加载
|
||||
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||
const [permission, setPermission] = useState<any>(null);
|
||||
const [permissionStatus, setPermissionStatus] = useState<'undetermined' | 'granted' | 'denied'>('undetermined');
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS !== 'web') {
|
||||
import('expo-camera').then((module) => {
|
||||
setCameraModule(module);
|
||||
const [perm, requestPerm] = module.useCameraPermissions();
|
||||
setPermission(perm);
|
||||
if (!perm?.granted) {
|
||||
requestPerm();
|
||||
}
|
||||
module.Camera.getCameraPermissionsAsync().then((result) => {
|
||||
setPermissionStatus(result.granted ? 'granted' : result.canAskAgain ? 'undetermined' : 'denied');
|
||||
if (!result.granted && result.canAskAgain) {
|
||||
module.Camera.requestCameraPermissionsAsync().then((reqResult) => {
|
||||
setPermissionStatus(reqResult.granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn('Failed to load expo-camera:', err);
|
||||
});
|
||||
}
|
||||
}, [visible]);
|
||||
@@ -237,7 +242,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
||||
}
|
||||
};
|
||||
|
||||
if (!cameraModule || !permission?.granted) {
|
||||
if (!cameraModule || permissionStatus !== 'granted') {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -251,9 +256,15 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => {
|
||||
if (cameraModule) {
|
||||
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
|
||||
setPermissionStatus(result.granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
54
src/components/common/HighlightText.tsx
Normal file
54
src/components/common/HighlightText.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Text, TextStyle, StyleProp } from 'react-native';
|
||||
|
||||
interface HighlightTextProps {
|
||||
text: string;
|
||||
keyword: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
|
||||
const parts = useMemo(() => {
|
||||
if (!text || !keyword) return [{ text, highlight: false }];
|
||||
|
||||
const result: Array<{ text: string; highlight: boolean }> = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
let lastIndex = 0;
|
||||
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < lowerText.length) {
|
||||
const index = lowerText.indexOf(lowerKeyword, searchFrom);
|
||||
if (index === -1) break;
|
||||
|
||||
if (index > lastIndex) {
|
||||
result.push({ text: text.substring(lastIndex, index), highlight: false });
|
||||
}
|
||||
result.push({ text: text.substring(index, index + keyword.length), highlight: true });
|
||||
lastIndex = index + keyword.length;
|
||||
searchFrom = lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push({ text: text.substring(lastIndex), highlight: false });
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : [{ text, highlight: false }];
|
||||
}, [text, keyword]);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{parts.map((part, i) =>
|
||||
part.highlight ? (
|
||||
<Text key={i} style={style}>
|
||||
{part.text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={i}>{part.text}</Text>
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightText;
|
||||
@@ -7,6 +7,7 @@ export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as HighlightText } from './HighlightText';
|
||||
export { default as Text } from './Text';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as Divider } from './Divider';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 支持响应式布局,宽屏下显示更大的搜索结果区域
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
@@ -80,7 +80,8 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||
const [currentKeyword, setCurrentKeyword] = useState('');
|
||||
|
||||
// 使用游标分页进行帖子搜索
|
||||
const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
|
||||
|
||||
const {
|
||||
list: searchResults,
|
||||
isLoading,
|
||||
@@ -100,20 +101,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
});
|
||||
return response;
|
||||
},
|
||||
{ pageSize: DEFAULT_PAGE_SIZE },
|
||||
{ query: '' }
|
||||
{ pageSize: DEFAULT_PAGE_SIZE, autoLoad: false },
|
||||
searchExtraParams
|
||||
);
|
||||
|
||||
// 用户搜索结果(保持原有分页方式)
|
||||
const [userResults, setUserResults] = useState<User[]>([]);
|
||||
const [userLoading, setUserLoading] = useState(false);
|
||||
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
// 当搜索词变化时重置
|
||||
useEffect(() => {
|
||||
if (currentKeyword) {
|
||||
refresh();
|
||||
refreshRef.current();
|
||||
} else {
|
||||
reset();
|
||||
resetRef.current();
|
||||
}
|
||||
}, [currentKeyword]);
|
||||
|
||||
@@ -132,11 +138,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
try {
|
||||
const searchType = getSearchType();
|
||||
|
||||
if (searchType === 'posts') {
|
||||
// 帖子搜索由 useCursorPagination 处理,这里只需触发刷新
|
||||
refresh();
|
||||
} else if (searchType === 'users') {
|
||||
// 用户搜索保持原有方式
|
||||
if (searchType === 'users') {
|
||||
setUserLoading(true);
|
||||
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
|
||||
setUserResults(usersResponse.list || []);
|
||||
@@ -259,6 +261,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
onAction={(action) => handlePostAction(post, action)}
|
||||
variant="list"
|
||||
features={isMobile ? 'compact' : 'full'}
|
||||
highlightKeyword={currentKeyword}
|
||||
/>
|
||||
))}
|
||||
</ResponsiveGrid>
|
||||
@@ -281,6 +284,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack }) => {
|
||||
onAction={(action) => handlePostAction(item, action)}
|
||||
variant="list"
|
||||
features="compact"
|
||||
highlightKeyword={currentKeyword}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
|
||||
@@ -578,6 +578,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanel}
|
||||
onFocusInput={() => textInputRef.current?.focus()}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
TextInput,
|
||||
Keyboard,
|
||||
BackHandler,
|
||||
Platform,
|
||||
@@ -60,6 +59,7 @@ import { ConversationListRow } from './components/ConversationListRow';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
// 导入扫码组件
|
||||
import { SearchBar, TabBar } from '../../components/business';
|
||||
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||
|
||||
// 系统通知会话特殊ID
|
||||
@@ -673,49 +673,40 @@ export const MessageListScreen: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染搜索模式UI
|
||||
// 渲染搜索模式UI(扁平化现代化设计)
|
||||
const renderSearchMode = () => (
|
||||
<View style={styles.searchModeContainer}>
|
||||
<View style={styles.searchInputContainer}>
|
||||
<AppBackButton onPress={handleCloseSearch} iconColor={colors.text.secondary} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
{/* 搜索头部 */}
|
||||
<View style={[styles.searchHeader, { paddingTop: Math.max(spacing.sm, insets.top + spacing.xs) }]}>
|
||||
<View style={styles.searchShell}>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={() => performSearch(searchText)}
|
||||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={handleCloseSearch}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 - 现代化风格 */}
|
||||
<View style={styles.searchTabContainer}>
|
||||
<TabBar
|
||||
tabs={['聊天记录', '用户']}
|
||||
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
|
||||
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
|
||||
variant="modern"
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText('')}>
|
||||
<MaterialCommunityIcons name="close-circle" size={18} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.searchTabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchTab, activeSearchTab === 'chat' && styles.searchTabActive]}
|
||||
onPress={() => setActiveSearchTab('chat')}
|
||||
>
|
||||
<Text style={activeSearchTab === 'chat' ? styles.searchTabTextActive : styles.searchTabText}>
|
||||
聊天记录
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchTab, activeSearchTab === 'user' && styles.searchTabActive]}
|
||||
onPress={() => setActiveSearchTab('user')}
|
||||
>
|
||||
<Text style={activeSearchTab === 'user' ? styles.searchTabTextActive : styles.searchTabText}>
|
||||
用户
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
{/* 搜索结果 */}
|
||||
<FlatList
|
||||
data={searchResults}
|
||||
renderItem={renderSearchResult}
|
||||
@@ -725,7 +716,10 @@ export const MessageListScreen: React.FC = () => {
|
||||
}
|
||||
return `user-${item.user?.id || index}`;
|
||||
}}
|
||||
contentContainerStyle={[styles.searchResultsContent, { paddingBottom: listBottomInset }]}
|
||||
contentContainerStyle={[
|
||||
styles.searchResultsContent,
|
||||
{ paddingBottom: listBottomInset },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -757,14 +751,18 @@ export const MessageListScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
|
||||
onPress={handleSearch}
|
||||
activeOpacity={0.7}
|
||||
<TouchableOpacity
|
||||
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
|
||||
onPress={handleSearch}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.searchBox}>
|
||||
<MaterialCommunityIcons name="magnify" size={18} color={colors.text.hint} />
|
||||
<Text style={styles.searchPlaceholder}>搜索</Text>
|
||||
<View pointerEvents="none">
|
||||
<SearchBar
|
||||
value=""
|
||||
onChangeText={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="搜索"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1026,19 +1024,7 @@ function createMessageListStyles(colors: AppColors) {
|
||||
paddingBottom: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.chat.surfaceInput,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
fontSize: 14,
|
||||
color: colors.text.hint,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
@@ -1062,60 +1048,49 @@ function createMessageListStyles(colors: AppColors) {
|
||||
},
|
||||
searchModeContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
searchInputContainer: {
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.chat.surfaceInput,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
paddingHorizontal: spacing.md,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
height: 40,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
searchInput: {
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: colors.text.primary,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
searchTabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
searchTab: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.chat.surfaceInput,
|
||||
borderRadius: 8,
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
},
|
||||
searchTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
searchTabText: {
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
searchTabTextActive: {
|
||||
color: colors.primary.contrast,
|
||||
searchTabContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}50`,
|
||||
},
|
||||
searchResultsContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
searchResultItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 10,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: `${colors.divider}50`,
|
||||
},
|
||||
searchResultContent: {
|
||||
flex: 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView, Platform } from 'react-native';
|
||||
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, Platform } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -17,7 +17,7 @@ import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUr
|
||||
import { useAppColors, spacing } from '../../../../theme';
|
||||
import { blurActiveElement } from '../../../../infrastructure/platform';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 表情尺寸配置 - 固定宽度 40px,使用 flex 布局
|
||||
const EMOJI_SIZES = {
|
||||
@@ -35,12 +35,14 @@ interface EmojiPanelProps {
|
||||
onInsertEmoji: (emoji: string) => void;
|
||||
onInsertSticker: (stickerUrl: string) => void;
|
||||
onClose: () => void;
|
||||
onFocusInput?: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
onInsertEmoji,
|
||||
onInsertSticker,
|
||||
onClose,
|
||||
onFocusInput,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const baseStyles = useChatScreenStyles();
|
||||
@@ -88,6 +90,25 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
});
|
||||
}, [emojiSize, baseStyles]);
|
||||
|
||||
// 每行显示的 emoji 数量(基于 40px 宽度 + 20px margin)
|
||||
const emojisPerRow = useMemo(() => {
|
||||
const itemTotalWidth = 40 + 20; // width + margin*2
|
||||
const containerWidth = SCREEN_WIDTH - spacing.sm * 2;
|
||||
return Math.floor(containerWidth / itemTotalWidth);
|
||||
}, []);
|
||||
|
||||
// 将 EMOJIS 分组成行数据,用于 FlatList 虚拟化渲染
|
||||
const emojiRows = useMemo(() => {
|
||||
const rows: { id: string; emojis: string[] }[] = [];
|
||||
for (let i = 0; i < EMOJIS.length; i += emojisPerRow) {
|
||||
rows.push({
|
||||
id: `emoji-row-${i}`,
|
||||
emojis: EMOJIS.slice(i, i + emojisPerRow),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [emojisPerRow]);
|
||||
|
||||
// 加载自定义表情
|
||||
const loadStickers = useCallback(async () => {
|
||||
setLoadingStickers(true);
|
||||
@@ -110,24 +131,55 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
|
||||
|
||||
|
||||
// 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度)
|
||||
const renderEmojiPanel = () => (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emojiContainer}
|
||||
showsVerticalScrollIndicator={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
// 处理 emoji 点击:插入后自动聚焦输入框
|
||||
const handleEmojiPress = useCallback((emoji: string) => {
|
||||
onInsertEmoji(emoji);
|
||||
// 使用 requestAnimationFrame 确保在 UI 更新后聚焦,减少卡顿感
|
||||
if (Platform.OS === 'web') {
|
||||
onFocusInput?.();
|
||||
} else {
|
||||
requestAnimationFrame(() => {
|
||||
onFocusInput?.();
|
||||
});
|
||||
}
|
||||
}, [onInsertEmoji, onFocusInput]);
|
||||
|
||||
// 渲染单行 emoji
|
||||
const renderEmojiRow = useCallback(({ item }: { item: { id: string; emojis: string[] } }) => (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
{item.emojis.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${emoji}-${index}`}
|
||||
key={`${item.id}-${index}`}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onInsertEmoji(emoji)}
|
||||
onPress={() => handleEmojiPress(emoji)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
), [styles.emojiItem, styles.emojiText, handleEmojiPress]);
|
||||
|
||||
// 渲染 Emoji 面板(使用 FlatList 虚拟化,只渲染可见行)
|
||||
const renderEmojiPanel = () => (
|
||||
<FlatList
|
||||
key="emoji-flatlist"
|
||||
data={emojiRows}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderEmojiRow}
|
||||
contentContainerStyle={styles.emojiContainer}
|
||||
showsVerticalScrollIndicator={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={true}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: 60,
|
||||
offset: 60 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// 添加表情(从相册选择)
|
||||
@@ -296,6 +348,7 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
key="sticker-flatlist"
|
||||
data={stickerListData}
|
||||
numColumns={4}
|
||||
keyExtractor={(item) => item.type === 'manage' ? 'manage-button' : item.sticker.id}
|
||||
|
||||
Reference in New Issue
Block a user