Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
557 lines
18 KiB
TypeScript
557 lines
18 KiB
TypeScript
/**
|
||
* 表情面板组件
|
||
* 支持 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;
|