Files
frontend/src/screens/message/components/ChatScreen/EmojiPanel.tsx
lan 3196972596
All checks were successful
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / build-and-push-web (push) Successful in 3m30s
Frontend CI / build-android-apk (push) Successful in 1h1m8s
refactor: restructure core architecture and responsive system
This commit implements a major architectural refactor to improve modularity, type safety, and maintainability across the codebase.

Key changes include:
- **Responsive System Refactor**: Migrated from a monolithic `useResponsive` hook to a set of specialized, granular hooks (`useBreakpoint`, `useOrientation`, `usePlatform`, etc.) located in `src/presentation/hooks/responsive`. This reduces unnecessary re-renders and improves developer experience.
- **Core Service Restructuring**: Introduced a new directory structure for services, including dedicated folders for `datasources`, `mappers`, and domain-specific types (`message`, `post`).
- **Data Layer Improvements**:
    - Centralized JSON parsing logic in `src/database/core/jsonUtils.ts`.
    - Cleaned up repository implementations by removing redundant local utility functions.
    - Refactored `MessageMapper` to use the new centralized JSON utility.
- **Type System Cleanup**:
    - Decomposed the large `src/types/dto.ts` into a modular `src/types/dto/` directory.
    - Simplified `src/types/index.ts` and introduced backward-compatible aliases for core entities.
- **Utility Consolidation**:
    - Created a centralized `src/utils/formatTime.ts` to replace fragmented date formatting logic across various screens and components.
    - Removed deprecated responsive utility files in favor of the new hook-based system.
- **Service Logic Refinement**: Refactored `ApiClient` and `WebSocketService` to use a centralized `showVerificationModal` service, removing duplicated state management for verification prompts.
2026-05-05 19:07:33 +08:00

625 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 表情面板组件
* 支持 Emoji 和自定义表情 Tab 切换
* 支持响应式布局(平板/桌面端显示更大的表情)
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, Platform } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { Text } from '../../../../components/common';
import { useChatScreenStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks';
import { EMOJIS } from './constants';
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '@/services/message';
import { useAppColors, spacing } from '../../../../theme';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
// 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = {
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
tablet: { size: 26, itemWidth: 40, itemHeight: 40 },
desktop: { size: 28, itemWidth: 40, itemHeight: 40 },
};
// Tab 类型
type TabType = 'emoji' | 'sticker';
type StickerListItem = { type: 'manage' } | { type: 'sticker'; sticker: CustomSticker };
type ManageListItem = { type: 'add' } | { type: 'sticker'; sticker: CustomSticker };
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();
const [activeTab, setActiveTab] = useState<TabType>('emoji');
const [stickers, setStickers] = useState<CustomSticker[]>([]);
const [loadingStickers, setLoadingStickers] = useState(false);
const [showManageModal, setShowManageModal] = useState(false);
const [selectedStickers, setSelectedStickers] = useState<Set<string>>(new Set());
const [manageMode, setManageMode] = useState(false);
const [addingSticker, setAddingSticker] = useState(false);
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
const isTablet = useBreakpointGTE('md');
// 获取表情尺寸
const emojiSize = useMemo(() => {
if (isWideScreen) return EMOJI_SIZES.desktop;
if (isTablet) return EMOJI_SIZES.tablet;
return EMOJI_SIZES.mobile;
}, [isWideScreen, isTablet]);
// 合并样式 - 使用 flex 布局,每个表情固定 40px 宽度
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
emojiContainer: {
flex: 1,
paddingTop: spacing.sm,
paddingHorizontal: spacing.sm,
backgroundColor: baseStyles.panelContainer.backgroundColor,
},
emojiItem: {
width: 40,
height: 40,
margin: 10,
alignItems: 'center',
justifyContent: 'center',
},
emojiText: {
fontSize: emojiSize.size,
lineHeight: emojiSize.size + 4,
},
});
}, [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);
try {
const data = await getCustomStickers();
setStickers(data);
} catch (error) {
console.error('加载自定义表情失败:', error);
} finally {
setLoadingStickers(false);
}
}, []);
// 切换到自定义表情 Tab 时加载数据
useEffect(() => {
if (activeTab === 'sticker') {
loadStickers();
}
}, [activeTab, loadStickers]);
// 处理 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={`${item.id}-${index}`}
style={styles.emojiItem}
onPress={() => handleEmojiPress(emoji)}
activeOpacity={0.7}
>
<Text style={styles.emojiText}>{emoji}</Text>
</TouchableOpacity>
))}
</View>
), [styles.emojiItem, styles.emojiText, handleEmojiPress]);
// 渲染 Emoji 面板(使用 FlashList 虚拟化,只渲染可见行)
const renderEmojiPanel = () => (
<View style={styles.emojiContainer}>
<FlashList
key="emoji-flatlist"
data={emojiRows}
keyExtractor={(item) => item.id}
renderItem={renderEmojiRow}
showsVerticalScrollIndicator={true}
keyboardShouldPersistTaps="handled"
style={{ flex: 1 }}
/>
</View>
);
// 添加表情(从相册选择)
const handleAddSticker = async () => {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('提示', '需要相册权限才能添加表情');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
allowsEditing: false,
});
if (!result.canceled && result.assets[0]) {
setAddingSticker(true);
const asset = result.assets[0];
try {
const sticker = await addStickerFromUrl(
asset.uri,
asset.width,
asset.height
);
if (sticker) {
await loadStickers();
Alert.alert('成功', '表情添加成功');
} else {
Alert.alert('提示', '该表情已存在');
}
} catch (error) {
console.error('添加表情失败:', error);
Alert.alert('错误', '添加表情失败,请重试');
} finally {
setAddingSticker(false);
}
}
} catch (error) {
console.error('选择图片失败:', error);
Alert.alert('错误', '选择图片失败');
}
};
// 打开管理界面
const handleOpenManage = () => {
blurActiveElement();
setShowManageModal(true);
setManageMode(false);
setSelectedStickers(new Set());
};
// 关闭管理界面
const handleCloseManage = () => {
setShowManageModal(false);
setManageMode(false);
setSelectedStickers(new Set());
};
// 切换选择表情
const toggleStickerSelection = (stickerId: string) => {
const newSelected = new Set(selectedStickers);
if (newSelected.has(stickerId)) {
newSelected.delete(stickerId);
} else {
newSelected.add(stickerId);
}
setSelectedStickers(newSelected);
};
// 删除选中的表情
const handleDeleteSelected = async () => {
if (selectedStickers.size === 0) {
Alert.alert('提示', '请先选择要删除的表情');
return;
}
Alert.alert(
'确认删除',
`确定要删除选中的 ${selectedStickers.size} 个表情吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
const stickerIds = Array.from(selectedStickers);
const result = await batchDeleteStickers(stickerIds);
// 重新加载表情列表
await loadStickers();
setSelectedStickers(new Set());
setManageMode(false);
if (result.failed > 0) {
Alert.alert('完成', `成功删除 ${result.success} 个表情,失败 ${result.failed}`);
} else {
Alert.alert('完成', `成功删除 ${result.success} 个表情`);
}
},
},
]
);
};
// 渲染管理按钮(表情面板第一位)- 填满格子
const renderManageButton = () => (
<TouchableOpacity
key="manage-button"
style={[styles.stickerItem, { alignItems: 'center', justifyContent: 'center' }]}
onPress={handleOpenManage}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="cog-outline"
size={32}
color={colors.chat.textSecondary}
/>
</TouchableOpacity>
);
const stickerListData = useMemo<StickerListItem[]>(() => {
return [{ type: 'manage' }, ...stickers.map(sticker => ({ type: 'sticker', sticker } as const))];
}, [stickers]);
const manageListData = useMemo<ManageListItem[]>(() => {
const base = stickers.map(sticker => ({ type: 'sticker', sticker } as const));
return manageMode ? base : ([{ type: 'add' } as const, ...base]);
}, [stickers, manageMode]);
const renderStickerListItem: ListRenderItem<StickerListItem> = useCallback(({ item }) => {
if (item.type === 'manage') {
return renderManageButton();
}
return (
<TouchableOpacity
style={styles.stickerItem}
onPress={() => onInsertSticker(item.sticker.url)}
activeOpacity={0.7}
>
<ExpoImage
source={{ uri: item.sticker.url }}
style={styles.stickerImage}
contentFit="cover"
cachePolicy="memory"
/>
</TouchableOpacity>
);
}, [styles.stickerItem, styles.stickerImage, onInsertSticker]);
// 渲染自定义表情面板
const renderStickerPanel = () => {
if (loadingStickers) {
return (
<View style={styles.stickerLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return (
<View style={styles.stickerGrid}>
<FlashList
key="sticker-flatlist"
data={stickerListData}
numColumns={4}
keyExtractor={(item) => item.type === 'manage' ? 'manage-button' : item.sticker.id}
renderItem={renderStickerListItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
style={{ flex: 1 }}
/>
</View>
);
};
// 渲染管理界面中的添加按钮(第一位)
const renderManageAddButton = () => (
<TouchableOpacity
key="manage-add-button"
style={[styles.stickerItem, styles.stickerAddButton]}
onPress={handleAddSticker}
disabled={addingSticker}
activeOpacity={0.7}
>
{addingSticker ? (
<ActivityIndicator size="large" color={colors.primary.main} />
) : (
<View style={styles.stickerAddContent}>
<MaterialCommunityIcons
name="plus"
size={36}
color={colors.primary.main}
/>
<Text style={[styles.stickerAddText, { color: colors.primary.main }]}></Text>
</View>
)}
</TouchableOpacity>
);
// 渲染管理界面中的表情项
const renderManageStickerItem = (sticker: CustomSticker) => {
const isSelected = selectedStickers.has(sticker.id);
return (
<TouchableOpacity
key={sticker.id}
style={[
styles.stickerItem,
manageMode && isSelected && styles.stickerItemSelected,
]}
onPress={() => {
if (manageMode) {
toggleStickerSelection(sticker.id);
}
}}
onLongPress={() => {
if (!manageMode) {
setManageMode(true);
toggleStickerSelection(sticker.id);
}
}}
activeOpacity={0.7}
>
<ExpoImage
source={{ uri: sticker.url }}
style={styles.stickerImage}
contentFit="cover"
cachePolicy="memory"
/>
{manageMode && (
<View style={styles.stickerCheckOverlay}>
<View style={[
styles.stickerCheckBox,
isSelected && styles.stickerCheckBoxSelected,
]}>
{isSelected && (
<MaterialCommunityIcons name="check" size={16} color="#FFF" />
)}
</View>
</View>
)}
</TouchableOpacity>
);
};
const renderManageListItem: ListRenderItem<ManageListItem> = useCallback(({ item }) => {
if (item.type === 'add') {
return renderManageAddButton();
}
return renderManageStickerItem(item.sticker);
}, [addingSticker, manageMode, selectedStickers, styles.stickerItem, styles.stickerImage]);
// 渲染管理模态框2/3 高度)
const renderManageModal = () => (
<Modal
visible={showManageModal}
animationType="slide"
transparent={true}
onRequestClose={handleCloseManage}
>
<View style={styles.manageModalOverlay}>
<TouchableOpacity
style={styles.manageModalBackdrop}
activeOpacity={1}
onPress={handleCloseManage}
/>
<View style={[styles.manageModalContainer, { height: SCREEN_HEIGHT * 2 / 3 }]}>
{/* 拖动指示器 */}
<View style={styles.manageModalHandle} />
{/* 头部 */}
<View style={styles.manageModalHeader}>
<TouchableOpacity onPress={handleCloseManage} style={styles.manageHeaderButton}>
<MaterialCommunityIcons name="chevron-left" size={28} color={colors.text.secondary} />
</TouchableOpacity>
<Text style={styles.manageModalTitle}></Text>
{manageMode ? (
<TouchableOpacity
onPress={() => {
setManageMode(false);
setSelectedStickers(new Set());
}}
style={styles.manageHeaderButton}
>
<Text style={[styles.manageDoneText, { color: colors.primary.main }]}></Text>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => setManageMode(true)}
style={styles.manageHeaderButton}
>
<Text style={[styles.manageSelectText, { color: colors.primary.main }]}></Text>
</TouchableOpacity>
)}
</View>
{/* 分隔线 */}
<View style={styles.manageHeaderDivider} />
{/* 表情网格 */}
{loadingStickers ? (
<View style={styles.stickerLoadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<View style={styles.manageStickerGrid}>
<FlashList
data={manageListData}
numColumns={4}
keyExtractor={(item) => item.type === 'add' ? 'manage-add-button' : item.sticker.id}
renderItem={renderManageListItem}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
style={{ flex: 1 }}
/>
</View>
)}
{/* 底部操作栏(管理模式下显示) */}
{manageMode && (
<View style={styles.manageBottomBar}>
<TouchableOpacity
style={styles.manageSelectAllButton}
onPress={() => {
if (selectedStickers.size === stickers.length) {
setSelectedStickers(new Set());
} else {
setSelectedStickers(new Set(stickers.map(s => s.id)));
}
}}
>
<Text style={[styles.manageSelectAllText, { color: colors.primary.main }]}>
{selectedStickers.size === stickers.length ? '取消全选' : '全选'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.manageDeleteButton,
selectedStickers.size === 0 && styles.manageDeleteButtonDisabled,
]}
onPress={handleDeleteSelected}
disabled={selectedStickers.size === 0}
>
<MaterialCommunityIcons name="delete-outline" size={20} color="#FFF" />
<Text style={styles.manageDeleteText}>
{selectedStickers.size > 0 && ` (${selectedStickers.size})`}
</Text>
</TouchableOpacity>
</View>
)}
{/* 使用提示 */}
{!manageMode && stickers.length > 0 && (
<View style={styles.manageTipBar}>
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
<Text style={[styles.manageTipText, { color: colors.text.hint }]}></Text>
</View>
)}
{/* 空状态 */}
{!loadingStickers && stickers.length === 0 && !manageMode && (
<View style={styles.manageEmptyContainer}>
<View style={[styles.manageEmptyIconBg, { backgroundColor: colors.primary.light + '20' }]}>
<MaterialCommunityIcons name="emoticon-happy-outline" size={48} color={colors.primary.main} />
</View>
<Text style={[styles.manageEmptyTitle, { color: colors.text.primary }]}></Text>
<Text style={[styles.manageEmptyDesc, { color: colors.text.secondary }]}> + </Text>
</View>
)}
</View>
</View>
</Modal>
);
return (
<View style={styles.panelContainer}>
{/* 内容区域 */}
<View style={styles.panelContent}>
{activeTab === 'emoji' ? renderEmojiPanel() : renderStickerPanel()}
</View>
{/* 底部 Tab 栏 - QQ/微信风格横向导航 */}
<View style={styles.panelTabBar}>
<View style={styles.panelTabScroll}>
<TouchableOpacity
style={[styles.panelTabItem, activeTab === 'emoji' && styles.panelTabItemActive]}
onPress={() => setActiveTab('emoji')}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="emoticon-happy-outline"
size={22}
color={activeTab === 'emoji' ? colors.primary.main : colors.chat.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.panelTabItem, activeTab === 'sticker' && styles.panelTabItemActive]}
onPress={() => setActiveTab('sticker')}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="image-multiple-outline"
size={22}
color={activeTab === 'sticker' ? colors.primary.main : colors.chat.textSecondary}
/>
</TouchableOpacity>
</View>
<TouchableOpacity style={styles.panelCloseButton} onPress={onClose} activeOpacity={0.7}>
<MaterialCommunityIcons name="keyboard-outline" size={20} color={colors.chat.textSecondary} />
</TouchableOpacity>
</View>
{/* 表情管理模态框 */}
{renderManageModal()}
</View>
);
};
export default EmojiPanel;
// 自定义表情按钮样式
const stickerButtonStyles = StyleSheet.create({
manageButton: {
backgroundColor: '#F5F5F7',
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
iconContainer: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 2,
elevation: 1,
},
});