Files
frontend/src/screens/message/components/ChatScreen/EmojiPanel.tsx
lan 3968660048 Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
2026-03-09 21:29:03 +08:00

557 lines
18 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, FlatList, ListRenderItem } from 'react-native';
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 { chatScreenStyles as baseStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { EMOJIS } from './constants';
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
import { colors, spacing } from '../../../../theme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 表情尺寸配置
const EMOJI_SIZES = {
mobile: { size: 28, itemHeight: 52 },
tablet: { size: 32, itemHeight: 60 },
desktop: { size: 36, itemHeight: 68 },
};
// 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;
}
export const EmojiPanel: React.FC<EmojiPanelProps> = ({
onInsertEmoji,
onInsertSticker,
onClose,
}) => {
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]);
// 表情网格列数
const emojiColumns = useMemo(() => {
if (isWideScreen) return 10;
if (isTablet) return 9;
return 8;
}, [isWideScreen, isTablet]);
// 合并样式
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
emojiItem: {
...baseStyles.emojiItem,
width: `${100 / emojiColumns}%`,
height: emojiSize.itemHeight,
},
emojiText: {
...baseStyles.emojiText,
fontSize: emojiSize.size,
lineHeight: emojiSize.size + 6,
},
});
}, [emojiSize, emojiColumns]);
// 加载自定义表情
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]);
const renderEmojiItem: ListRenderItem<string> = useCallback(({ item }) => (
<TouchableOpacity
style={styles.emojiItem}
onPress={() => onInsertEmoji(item)}
activeOpacity={0.7}
>
<Text style={styles.emojiText}>{item}</Text>
</TouchableOpacity>
), [styles.emojiItem, styles.emojiText, onInsertEmoji]);
// 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿)
const renderEmojiPanel = () => (
<FlatList
data={EMOJIS}
key={emojiColumns}
numColumns={emojiColumns}
keyExtractor={(item, index) => `${item}-${index}`}
renderItem={renderEmojiItem}
contentContainerStyle={styles.emojiGrid}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={emojiColumns * 4}
maxToRenderPerBatch={emojiColumns * 4}
windowSize={7}
removeClippedSubviews={true}
getItemLayout={(_, index) => ({
length: emojiSize.itemHeight,
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
index,
})}
/>
);
// 添加表情(从相册选择)
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 = () => {
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, styles.stickerManageButton]}
onPress={handleOpenManage}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="cog-outline"
size={40}
color="#666"
/>
</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 (
<FlatList
data={stickerListData}
numColumns={4}
keyExtractor={(item) => item.type === 'manage' ? 'manage-button' : item.sticker.id}
renderItem={renderStickerListItem}
contentContainerStyle={styles.stickerGrid}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={16}
maxToRenderPerBatch={16}
windowSize={7}
removeClippedSubviews={true}
/>
);
};
// 渲染管理界面中的添加按钮(第一位)
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>
) : (
<FlatList
data={manageListData}
numColumns={4}
keyExtractor={(item) => item.type === 'add' ? 'manage-add-button' : item.sticker.id}
renderItem={renderManageListItem}
contentContainerStyle={styles.manageStickerGrid}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={16}
maxToRenderPerBatch={16}
windowSize={7}
removeClippedSubviews={true}
/>
)}
{/* 底部操作栏(管理模式下显示) */}
{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 栏 */}
<View style={styles.panelTabBar}>
<TouchableOpacity
style={[styles.panelTab, activeTab === 'emoji' && styles.panelTabActive]}
onPress={() => setActiveTab('emoji')}
>
<Text style={styles.panelTabEmoji}>😊</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.panelTab, activeTab === 'sticker' && styles.panelTabActive]}
onPress={() => setActiveTab('sticker')}
>
<MaterialCommunityIcons
name="emoticon-happy-outline"
size={24}
color={activeTab === 'sticker' ? colors.primary.main : '#666'}
/>
</TouchableOpacity>
<View style={styles.panelTabDivider} />
<TouchableOpacity style={styles.panelCloseButton} onPress={onClose}>
<MaterialCommunityIcons name="keyboard" size={24} color="#666" />
</TouchableOpacity>
</View>
{/* 表情管理模态框 */}
{renderManageModal()}
</View>
);
};
export default EmojiPanel;