Initial frontend repository commit.

Include app source and update .gitignore to exclude local release artifacts and signing files.

Made-with: Cursor
This commit is contained in:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
/**
* 聊天头部组件
* 支持响应式布局(宽屏下显示更多信息)
*/
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../../components/common';
import { colors, spacing } from '../../../../theme';
import { chatScreenStyles as baseStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { ChatHeaderProps } from './types';
export const ChatHeader: React.FC<ChatHeaderProps> = ({
isGroupChat,
groupInfo,
otherUser,
routeGroupName,
typingHint,
onBack,
onTitlePress,
onMorePress,
}) => {
// 响应式布局
const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 合并样式
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
headerContainer: {
...baseStyles.headerContainer,
maxWidth: isWideScreen ? 1200 : undefined,
alignSelf: isWideScreen ? 'center' : undefined,
width: isWideScreen ? '100%' : undefined,
},
header: {
...baseStyles.header,
paddingHorizontal: isWideScreen ? spacing.xl : spacing.md,
},
headerName: {
...baseStyles.headerName,
fontSize: isWideScreen ? 19 : 17,
},
memberCount: {
...baseStyles.memberCount,
fontSize: isWideScreen ? 14 : 13,
},
typingHint: {
...baseStyles.typingHint,
fontSize: isWideScreen ? 13 : 12,
},
});
}, [isWideScreen]);
// 获取显示标题
const getDisplayTitle = () => {
if (isGroupChat) {
return routeGroupName || groupInfo?.name || '群聊';
}
return otherUser?.nickname || '用户';
};
return (
<SafeAreaView edges={['top']} style={styles.headerContainer}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={onBack}
>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
</TouchableOpacity>
<View style={styles.headerCenter}>
<TouchableOpacity
style={styles.titleRow}
onPress={onTitlePress}
activeOpacity={0.75}
>
{isGroupChat ? (
<>
<View style={styles.titleLeading}>
<MaterialCommunityIcons name="account-group" size={20} color={colors.primary.main} />
</View>
<View style={styles.titleContent}>
<Text style={styles.headerName} numberOfLines={1}>{getDisplayTitle()}</Text>
<View style={styles.subtitleRow}>
<Text style={styles.memberCount}>{groupInfo?.member_count || 0}</Text>
{typingHint && (
<Text style={styles.typingHint} numberOfLines={1}>{typingHint}</Text>
)}
</View>
</View>
</>
) : (
<>
<Avatar
source={otherUser?.avatar || null}
size={32}
name={otherUser?.nickname || ''}
/>
<View style={styles.titleContent}>
<Text style={styles.headerName} numberOfLines={1}>{getDisplayTitle()}</Text>
{typingHint && (
<View style={styles.subtitleRow}>
<Text style={styles.typingHint} numberOfLines={1}>{typingHint}</Text>
</View>
)}
</View>
</>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
</TouchableOpacity>
</View>
</SafeAreaView>
);
};
export default ChatHeader;

View File

@@ -0,0 +1,173 @@
/**
* 聊天输入框组件
* 支持响应式布局(宽屏下居中显示)
*/
import React, { useMemo } from 'react';
import { View, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '../../../../components/common';
import { colors } from '../../../../theme';
import { chatScreenStyles as baseStyles } from './styles';
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { ChatInputProps, GroupMessage, SenderInfo } from './types';
import { extractTextFromSegments } from '../../../../types/dto';
export const ChatInput: React.FC<ChatInputProps & {
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
getSenderInfo: (senderId: string) => SenderInfo;
}> = ({
inputText,
onInputChange,
onSend,
onToggleEmoji,
onToggleMore,
activePanel,
sending,
isMuted,
isGroupChat,
muteAll,
replyingTo,
onCancelReply,
onFocus,
restrictionHint,
disableMore = false,
currentUser,
otherUser,
getSenderInfo,
}) => {
const isDisabled = isGroupChat && isMuted;
// 获取当前用户ID
const currentUserId = currentUser?.id || '';
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles;
const inputContainerStyle = useMemo(() => ([
styles.inputContainer,
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.inputContainer]);
// 回复预览组件 - 定义在内部以访问 styles
const ReplyPreview: React.FC<{
replyingTo: GroupMessage;
onCancel: () => void;
}> = ({ replyingTo, onCancel }) => {
const senderInfo = replyingTo.sender_id === currentUserId
? { nickname: '我', avatar: currentUser?.avatar }
: isGroupChat
? getSenderInfo(replyingTo.sender_id)
: { nickname: otherUser?.nickname || '对方', avatar: otherUser?.avatar };
return (
<View style={styles.replyPreviewContainer}>
<View style={styles.replyPreviewContent}>
<MaterialCommunityIcons name="reply" size={16} color={colors.primary.main} />
<View style={styles.replyPreviewText}>
<Text style={styles.replyPreviewName}> {senderInfo.nickname}</Text>
<Text style={styles.replyPreviewMessage} numberOfLines={1}>
{replyingTo.segments?.some(seg => seg.type === 'image') ? '[图片]' : extractTextFromSegments(replyingTo.segments)}
</Text>
</View>
</View>
<TouchableOpacity onPress={onCancel} style={styles.replyPreviewClose}>
<MaterialCommunityIcons name="close" size={18} color="#999" />
</TouchableOpacity>
</View>
);
};
return (
<View style={inputContainerStyle}>
{/* 回复预览 */}
{replyingTo && (
<ReplyPreview
replyingTo={replyingTo}
onCancel={onCancelReply}
/>
)}
{/* 禁言提示 */}
{isGroupChat && isMuted && (
<View style={styles.mutedBanner}>
<MaterialCommunityIcons name="minus-circle" size={16} color="#FF3B30" />
<Text style={styles.mutedBannerText}>
{muteAll ? '全员禁言中,暂无法发送消息' : '你已被禁言,暂无法发送消息'}
</Text>
</View>
)}
{!!restrictionHint && (
<View style={styles.mutedBanner}>
<MaterialCommunityIcons name="information-outline" size={16} color={colors.warning.main} />
<Text style={styles.mutedBannerText}>{restrictionHint}</Text>
</View>
)}
<View style={[styles.inputInner, isDisabled && styles.inputInnerMuted]}>
<TouchableOpacity
style={[styles.iconButton, activePanel === 'emoji' && styles.iconButtonActive]}
onPress={onToggleEmoji}
disabled={isDisabled}
>
<MaterialCommunityIcons
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
size={26}
color={isDisabled ? '#CCC' : (activePanel === 'emoji' ? colors.primary.main : '#666')}
/>
</TouchableOpacity>
<View style={styles.inputBox}>
<TextInput
style={[styles.input, isDisabled && styles.inputMuted]}
placeholder={isGroupChat ? (isMuted ? (muteAll ? "全员禁言中..." : "你已被禁言...") : "发送消息,@提及成员...") : "发送消息..."}
placeholderTextColor={isDisabled ? '#CCC' : '#999'}
value={inputText}
onChangeText={onInputChange}
multiline
maxLength={500}
returnKeyType="send"
onSubmitEditing={onSend}
blurOnSubmit={false}
editable={!isDisabled}
onFocus={onFocus}
/>
</View>
{inputText.trim() && !sending && !isDisabled ? (
<TouchableOpacity
style={styles.sendButtonActive}
onPress={onSend}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
</TouchableOpacity>
) : sending ? (
<TouchableOpacity
style={[styles.sendButtonActive, styles.sendButtonDisabled]}
disabled
>
<ActivityIndicator size="small" color="#FFF" />
</TouchableOpacity>
) : (
<TouchableOpacity
style={[styles.iconButton, activePanel === 'more' && styles.iconButtonActive]}
onPress={onToggleMore}
disabled={isDisabled || disableMore}
>
<MaterialCommunityIcons
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
size={26}
color={isDisabled || disableMore ? '#CCC' : (activePanel === 'more' ? colors.primary.main : '#666')}
/>
</TouchableOpacity>
)}
</View>
</View>
);
};
export default ChatInput;

View File

@@ -0,0 +1,556 @@
/**
* 表情面板组件
* 支持 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;

View File

@@ -0,0 +1,296 @@
/**
* 长按菜单组件 - 气泡旁边弹出形式类似手机QQ
*/
import React, { useRef, useEffect } from 'react';
import {
View,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
Animated,
Alert,
Clipboard,
Dimensions,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { chatScreenStyles as styles } from './styles';
import { LongPressMenuProps } from './types';
import { RECALL_TIME_LIMIT } from './constants';
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export const LongPressMenu: React.FC<LongPressMenuProps> = ({
visible,
message,
currentUserId,
position,
onClose,
onReply,
onRecall,
onDelete,
onAddSticker,
}) => {
const scaleAnimation = useRef(new Animated.Value(0)).current;
const opacityAnimation = useRef(new Animated.Value(0)).current;
// 显示动画 - 缩放弹出
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scaleAnimation, {
toValue: 1,
useNativeDriver: true,
friction: 8,
tension: 100,
}),
Animated.timing(opacityAnimation, {
toValue: 1,
duration: 150,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(scaleAnimation, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(opacityAnimation, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
}
}, [visible, scaleAnimation, opacityAnimation]);
if (!message) return null;
const isMyMessage = message.sender_id === currentUserId;
const messageTime = new Date(message.created_at).getTime();
const now = Date.now();
const canRecall = isMyMessage &&
message.status !== 'recalled' &&
(now - messageTime) < RECALL_TIME_LIMIT;
// 检查是否是图片消息
const segments = Array.isArray(message.segments) ? message.segments : [];
const imageSegment = segments.find(s => s.type === 'image');
const isImageMessage = !!imageSegment;
const imageUrl = imageSegment ? (imageSegment.data as ImageSegmentData)?.url : null;
// 复制文本到剪贴板
const handleCopy = () => {
const text = extractTextFromSegments(message.segments);
if (text) {
Clipboard.setString(text);
onClose();
}
};
// 多选功能(这里简化为复制全部)
const handleMultiSelect = () => {
const text = extractTextFromSegments(message.segments);
if (text) {
Clipboard.setString(text);
onClose();
}
};
const handleDelete = () => {
Alert.alert(
'确认删除',
'删除后,该消息将从你的设备上移除。确定要删除吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => {
onDelete(message.id);
onClose();
},
},
]
);
};
// 添加到表情
const handleAddSticker = async () => {
if (!imageUrl) return;
onClose();
// 检查是否已存在
const exists = await isStickerExists(imageUrl);
if (exists) {
Alert.alert('提示', '该表情已存在');
return;
}
try {
const sticker = await addStickerFromUrl(imageUrl);
if (sticker) {
Alert.alert('成功', '已添加到表情');
onAddSticker?.(imageUrl);
} else {
Alert.alert('提示', '该表情已存在');
}
} catch (error) {
Alert.alert('失败', '添加表情失败,请重试');
}
};
// 菜单项配置 - 类似QQ的横向排列
const menuItems = [
{
id: 'reply',
label: '回复',
icon: 'reply',
onPress: () => {
onReply(message);
onClose();
},
show: true,
},
{
id: 'copy',
label: '复制',
icon: 'content-copy',
onPress: handleCopy,
show: !message.is_system_notice && message.status !== 'recalled',
},
{
id: 'multiSelect',
label: '多选',
icon: 'checkbox-multiple-marked-outline',
onPress: handleMultiSelect,
show: true,
},
{
id: 'addSticker',
label: '添加表情',
icon: 'emoticon-happy-outline',
onPress: handleAddSticker,
show: isImageMessage && message.status !== 'recalled',
},
{
id: 'recall',
label: '撤回',
icon: 'undo',
onPress: () => {
onRecall(message.id);
onClose();
},
show: canRecall,
},
{
id: 'delete',
label: '删除',
icon: 'delete-outline',
onPress: handleDelete,
show: true,
},
].filter(item => item.show);
// 计算菜单位置 - 紧贴手指位置显示在气泡上方
const calculatePosition = () => {
if (!position) {
// 默认居中显示
return {
top: SCREEN_HEIGHT / 2 - 50,
left: SCREEN_WIDTH / 2 - 120,
};
}
const menuWidth = Math.max(menuItems.length * 56, 180);
const menuHeight = 54;
// 使用按压点位置(手指位置)
const pressX = position.pressX ?? position.x + position.width / 2;
const pressY = position.pressY ?? position.y;
// 在手指位置上方显示菜单,留出 8px 间距
let top = pressY - menuHeight - 8;
// 水平居中于手指位置
let left = pressX - menuWidth / 2;
// 如果上方空间不足,显示在手指位置下方
if (top < 50) {
top = pressY + 8;
}
// 确保不超出屏幕左右边界
const minLeft = 8;
const maxLeft = SCREEN_WIDTH - menuWidth - 8;
if (left < minLeft) left = minLeft;
if (left > maxLeft) left = maxLeft;
return { top, left };
};
const { top, left } = calculatePosition();
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.qqMenuOverlay}>
<TouchableWithoutFeedback>
<Animated.View
style={[
styles.qqMenuContainer,
{
top,
left,
opacity: opacityAnimation,
transform: [
{ scale: scaleAnimation },
],
},
]}
>
{/* 操作按钮横向排列 */}
<View style={styles.qqMenuItemsRow}>
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.id}
style={[
styles.qqMenuItem,
index < menuItems.length - 1 && styles.qqMenuItemWithBorder,
]}
onPress={item.onPress}
activeOpacity={0.6}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color="#FFFFFF"
/>
<View style={styles.qqMenuItemLabelContainer}>
<Animated.Text style={styles.qqMenuItemLabel}>
{item.label}
</Animated.Text>
</View>
</TouchableOpacity>
))}
</View>
</Animated.View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
);
};
export default LongPressMenu;

View File

@@ -0,0 +1,102 @@
/**
* @成员选择面板组件
*/
import React from 'react';
import { View, ScrollView, TouchableOpacity } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text } from '../../../../components/common';
import { chatScreenStyles as styles } from './styles';
import { MentionPanelProps } from './types';
export const MentionPanel: React.FC<MentionPanelProps> = ({
members,
currentUserId,
mentionQuery,
currentUserRole,
onSelectMention,
onMentionAll,
onClose,
}) => {
// 过滤成员列表
const filteredMembers = members.filter(member => {
if (member.user_id === currentUserId) return false; // 排除自己
const displayName = member.nickname || member.user?.nickname || '';
return displayName.toLowerCase().includes(mentionQuery);
});
// 检查是否可以@所有人(仅群主和管理员)
const canMentionAll = currentUserRole === 'owner' || currentUserRole === 'admin';
return (
<View style={styles.mentionPanelContainer}>
{/* 拖动把手 */}
<View style={styles.mentionPanelDragHandle}>
<View style={styles.mentionPanelDragBar} />
</View>
{/* 标题栏 */}
<View style={styles.mentionPanelHeader}>
<Text style={styles.mentionPanelTitle}>@ </Text>
<TouchableOpacity style={styles.mentionPanelCloseBtn} onPress={onClose}>
<MaterialCommunityIcons name="close" size={14} color="#999" />
</TouchableOpacity>
</View>
<ScrollView
style={styles.mentionList}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={true}
nestedScrollEnabled={true}
>
{/* @所有人选项 */}
{canMentionAll && (
<TouchableOpacity style={styles.mentionItemAll} onPress={onMentionAll}>
<View style={styles.mentionAllIcon}>
<MaterialCommunityIcons name="account-group" size={22} color="#FFF" />
</View>
<View style={styles.mentionItemInfo}>
<Text style={styles.mentionItemNameAll}></Text>
<Text style={styles.mentionItemSub}></Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color="rgba(255,107,53,0.4)" />
</TouchableOpacity>
)}
{/* 成员列表 */}
{filteredMembers.map(member => (
<TouchableOpacity
key={member.id}
style={styles.mentionItem}
onPress={() => onSelectMention(member)}
>
<Avatar
source={member.user?.avatar || null}
size={38}
name={member.nickname || member.user?.nickname || ''}
/>
<View style={styles.mentionItemInfo}>
<Text style={styles.mentionItemName}>
{member.nickname || member.user?.nickname || '用户'}
</Text>
{member.role === 'owner' && (
<Text style={styles.mentionItemRole}></Text>
)}
{member.role === 'admin' && (
<Text style={styles.mentionItemRole}></Text>
)}
</View>
</TouchableOpacity>
))}
{filteredMembers.length === 0 && (
<View style={styles.mentionEmpty}>
<Text style={styles.mentionEmptyText}></Text>
</View>
)}
</ScrollView>
</View>
);
};
export default MentionPanel;

View File

@@ -0,0 +1,471 @@
/**
* 消息气泡组件
* 支持消息链Segment数组渲染
* 支持响应式布局(宽屏下优化显示)
*/
import React, { useRef, useMemo } from 'react';
import {
View,
TouchableOpacity,
Image,
findNodeHandle,
UIManager,
GestureResponderEvent,
StyleSheet,
Dimensions,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
import { chatScreenStyles as baseStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
import { MessageSegmentsRenderer } from './SegmentRenderer';
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
// 获取屏幕宽度
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 消息内容最大宽度比例
const MAX_WIDTH_RATIO = {
mobile: 0.75,
tablet: 0.65,
desktop: 0.55,
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({
message,
index,
currentUserId,
currentUser,
otherUser,
isGroupChat,
groupMembers,
otherUserLastReadSeq,
selectedMessageId,
messageMap,
onLongPress,
onAvatarPress,
onAvatarLongPress,
formatTime,
shouldShowTime,
onImagePress,
}) => {
const bubbleRef = useRef<View>(null);
const pressPositionRef = useRef({ x: 0, y: 0 });
// 响应式布局
const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
const isTablet = useBreakpointGTE('md');
// 计算消息内容最大宽度
const maxContentWidth = useMemo(() => {
const ratio = isWideScreen ? MAX_WIDTH_RATIO.desktop :
isTablet ? MAX_WIDTH_RATIO.tablet :
MAX_WIDTH_RATIO.mobile;
return Math.min(SCREEN_WIDTH * ratio, 600);
}, [isWideScreen, isTablet]);
// 合并样式
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
messageContent: {
...baseStyles.messageContent,
maxWidth: maxContentWidth,
},
messageImage: {
...baseStyles.messageImage,
width: isWideScreen ? 280 : 220,
height: isWideScreen ? 280 : 220,
},
});
}, [maxContentWidth, isWideScreen]);
// 系统通知消息特殊处理
// 支持两种判断方式:
// 1. is_system_notice 字段WebSocket 推送时设置)
// 2. category === 'notification'(从数据库加载时使用)
// 3. sender_id === '10000'(系统发送者兜底)
const isSystemNotice = message.is_system_notice || message.category === 'notification' || message.sender_id === '10000';
const isMe = message.sender_id === currentUserId;
const showTime = shouldShowTime(index);
const isRecalled = message.status === 'recalled';
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
// 检查是否有消息链(必须使用 segments 格式)
// 安全检查:确保 segments 是数组
const segments = Array.isArray(message.segments) ? message.segments : [];
const hasSegments = segments.length > 0;
// 检查是否是图片消息
const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image');
// 检查是否是纯图片消息只有图片segment没有文本等其他内容
const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type);
// 提取所有图片 segments
const imageSegments = segments
.filter(s => s.type === 'image')
.map((s, idx) => {
const data = s.data as ImageSegmentData;
return {
id: `img-${message.id}-${idx}`,
url: data.url,
thumbnail_url: data.thumbnail_url,
width: data.width,
height: data.height,
};
});
// 检查当前消息是否被选中
const isSelected = selectedMessageId === String(message.id);
// 获取发送者信息(群聊)- 供子组件使用
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
if (senderId === currentUserId) {
return {
nickname: currentUser?.nickname || '我',
avatar: currentUser?.avatar || null,
userId: currentUserId,
};
}
// 兜底:从群成员列表查找
const member = groupMembers.find(m => m.user_id === senderId);
if (member) {
return {
nickname: member.nickname || member.user?.nickname || '用户',
avatar: member.user?.avatar || null,
userId: member.user_id,
};
}
return {
nickname: '用户',
avatar: null,
userId: senderId,
};
}, [currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
// 使用useMemo确保senderInfo在message.sender变化时重新计算
const senderInfo = React.useMemo((): SenderInfo | null => {
if (!isGroupChat) return null;
const senderId = message.sender_id;
if (senderId === currentUserId) {
return {
nickname: currentUser?.nickname || '我',
avatar: currentUser?.avatar || null,
userId: currentUserId,
};
}
// 优先使用消息对象中携带的sender信息MessageManager 已填充)
if (message.sender && (message.sender.avatar || message.sender.nickname)) {
return {
nickname: message.sender.nickname || message.sender.username || '用户',
avatar: message.sender.avatar || null,
userId: senderId,
};
}
// 兜底:从群成员列表查找
const member = groupMembers.find(m => m.user_id === senderId);
if (member) {
return {
nickname: member.nickname || member.user?.nickname || '用户',
avatar: member.user?.avatar || null,
userId: member.user_id,
};
}
return {
nickname: '用户',
avatar: null,
userId: senderId,
};
}, [isGroupChat, message.sender_id, message.sender, currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
// 创建成员映射(用于@高亮)
const memberMap = useMemo(() => {
const map = new Map<string, { nickname: string; user_id: string }>();
groupMembers.forEach(m => {
map.set(m.user_id, {
nickname: m.nickname || m.user?.nickname || '用户',
user_id: m.user_id,
});
});
return map;
}, [groupMembers]);
// 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
pressPositionRef.current = { x: pageX, y: pageY };
};
// 处理长按并获取位置
const handleLongPress = () => {
if (bubbleRef.current) {
const node = findNodeHandle(bubbleRef.current);
if (node) {
UIManager.measureInWindow(node, (x, y, width, height) => {
const position: MenuPosition = {
x,
y,
width,
height,
pressX: pressPositionRef.current.x,
pressY: pressPositionRef.current.y,
};
onLongPress(message, position);
});
} else {
onLongPress(message);
}
} else {
onLongPress(message);
}
};
// 渲染消息内容
const renderMessageContent = () => {
// 撤回消息
if (isRecalled) {
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
styles.recalledBubble,
]}>
<Text
style={[
isMe ? styles.myBubbleText : styles.theirBubbleText,
styles.recalledText,
]}
selectable={false}
>
</Text>
</View>
);
}
// 使用消息链渲染(必须使用 segments 格式)
// 从 segments 中获取被回复消息的 ID 和 seq然后从 messageMap 中查找
const getReplyMessage = (): GroupMessage | undefined => {
const replySegment = segments.find(s => s.type === 'reply');
if (replySegment && messageMap) {
const replyData = replySegment.data as { id: string; seq?: number };
const replyId = String(replyData.id);
// 首先尝试通过 ID 查找
let found = messageMap.get(replyId);
// 如果通过 ID 找不到,尝试通过 seq 查找
if (!found && replyData.seq !== undefined) {
found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq);
}
return found;
}
return undefined;
};
// 如果没有 segments显示空内容或错误提示
if (!hasSegments) {
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
]}>
<Text
style={[
isMe ? styles.myBubbleText : styles.theirBubbleText,
{ opacity: 0.5 },
]}
selectable={false}
>
[]
</Text>
</View>
);
}
// 检查是否有回复,用于调整气泡样式
const hasReply = segments.some(s => s.type === 'reply');
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
hasReply && segmentStyles.replyBubble,
isPureImageMessage && segmentStyles.pureImageBubble,
]}>
<MessageSegmentsRenderer
segments={segments}
isMe={isMe}
currentUserId={currentUserId}
memberMap={memberMap}
replyMessage={getReplyMessage()}
getSenderInfo={getSenderInfo}
onAtPress={(userId) => {
// TODO: 跳转到用户资料页
console.log('At pressed:', userId);
}}
onReplyPress={(messageId) => {
// TODO: 滚动到被回复的消息
console.log('Reply pressed:', messageId);
}}
onImagePress={(url) => {
// 查找点击的图片索引
const clickIndex = imageSegments.findIndex(img => img.url === url);
if (clickIndex !== -1 && onImagePress) {
onImagePress(imageSegments, clickIndex);
}
}}
onImageLongPress={(position?: { x: number; y: number }) => {
// 图片长按触发和消息气泡一样的菜单
// 如果有位置信息,直接使用;否则使用气泡位置
if (position) {
onLongPress(message, {
x: 0,
y: 0,
width: 0,
height: 0,
pressX: position.x,
pressY: position.y,
});
} else {
handleLongPress();
}
}}
onLinkPress={(url) => {
console.log('Link pressed:', url);
}}
/>
</View>
);
};
// 系统通知消息渲染
if (isSystemNotice) {
return (
<View>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(message.created_at)}
</Text>
</View>
)}
<View style={styles.systemNoticeContainer}>
<Text style={styles.systemNoticeText}>{message.notice_content || extractTextFromSegments(message.segments)}</Text>
</View>
</View>
);
}
return (
<TouchableOpacity
onPressIn={handlePressIn}
onLongPress={handleLongPress}
activeOpacity={1}
>
<View ref={bubbleRef}>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(message.created_at)}
</Text>
</View>
)}
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
{/* 群聊模式:显示发送者头像和昵称 */}
{isGroupChat && !isMe && senderInfo && (
<TouchableOpacity
style={styles.groupAvatarWrapper}
onPress={() => senderInfo?.userId && onAvatarPress(senderInfo.userId)}
onLongPress={() => onAvatarLongPress(message.sender_id, senderInfo.nickname)}
delayLongPress={500}
>
<Avatar
source={senderInfo.avatar}
size={36}
name={senderInfo.nickname}
/>
</TouchableOpacity>
)}
{/* 私聊模式:显示对方头像 */}
{!isGroupChat && !isMe && (
<TouchableOpacity
style={styles.avatarWrapper}
onPress={() => otherUser?.id && onAvatarPress(String(otherUser.id))}
>
<Avatar
source={otherUser?.avatar || null}
size={36}
name={otherUser?.nickname || ''}
/>
</TouchableOpacity>
)}
<View style={styles.messageContent}>
{/* 群聊模式:显示发送者昵称 */}
{isGroupChat && !isMe && senderInfo && (
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
)}
{renderMessageContent()}
{/* 私聊模式:显示已读标记 */}
{isLastReadMessage && (
<View style={styles.readStatusContainer}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
</View>
)}
</View>
{/* 自己的头像 */}
{isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={currentUser?.avatar || null}
size={36}
name={currentUser?.nickname || ''}
/>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
};
// Segment 消息的特殊样式 - 自适应宽度
const segmentStyles = StyleSheet.create({
replyBubble: {
// 有回复消息时,使用自适应宽度
minWidth: 120,
},
pureImageBubble: {
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
paddingHorizontal: 0,
paddingVertical: 0,
overflow: 'hidden',
},
});
export default MessageBubble;

View File

@@ -0,0 +1,42 @@
/**
* 更多功能面板组件
*/
import React from 'react';
import { View, TouchableOpacity } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '../../../../components/common';
import { chatScreenStyles as styles } from './styles';
import { MORE_ACTIONS } from './constants';
import { MorePanelProps } from './types';
export const MorePanel: React.FC<MorePanelProps> = ({
onAction,
disabledActionIds = [],
}) => {
const disabledSet = new Set(disabledActionIds);
return (
<View style={styles.panelContainer}>
<View style={styles.moreGrid}>
{MORE_ACTIONS.map((action) => {
const disabled = disabledSet.has(action.id);
return (
<TouchableOpacity
key={action.id}
style={[styles.moreItem, disabled ? { opacity: 0.45 } : null]}
onPress={() => onAction(action.id)}
disabled={disabled}
>
<View style={styles.moreIconContainer}>
<MaterialCommunityIcons name={action.icon as any} size={28} color={action.color} />
</View>
<Text style={styles.moreItemText}>{action.name}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
};
export default MorePanel;

View File

@@ -0,0 +1,938 @@
/**
* Segment 渲染组件
* 用于渲染消息链中的各种 Segment 类型
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Linking,
Image,
GestureResponderEvent,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { VideoPlayerModal } from '../../../../components/common/VideoPlayerModal';
import {
MessageSegment,
AtSegmentData,
ReplySegmentData,
ImageSegmentData,
VoiceSegmentData,
VideoSegmentData,
FileSegmentData,
FaceSegmentData,
LinkSegmentData,
TextSegmentData,
MessageResponse,
UserDTO,
extractTextFromSegments,
} from '../../../../types/dto';
import { colors, spacing } from '../../../../theme';
import { SenderInfo } from './types';
const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
// Segment 渲染器 Props
export interface SegmentRendererProps {
segment: MessageSegment;
// 当前用户ID用于判断@是否高亮
currentUserId?: string;
// 群成员列表,用于获取@用户的昵称
memberMap?: Map<string, { nickname: string; user_id: string }>;
// 是否是自己发送的消息
isMe: boolean;
// @点击回调
onAtPress?: (userId: string) => void;
// 回复点击回调
onReplyPress?: (messageId: string) => void;
// 图片点击回调
onImagePress?: (url: string) => void;
// 图片长按回调(触发消息气泡菜单)
onImageLongPress?: (position?: { x: number; y: number }) => void;
// 链接点击回调
onLinkPress?: (url: string) => void;
}
// 回复预览 Props
export interface ReplyPreviewSegmentProps {
replyData: ReplySegmentData;
replyMessage?: MessageResponse;
isMe: boolean;
onPress?: () => void;
getSenderInfo?: (senderId: string) => SenderInfo;
}
/**
* 渲染单个 Segment
*/
export const renderSegment = (
segment: MessageSegment,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { isMe } = props;
switch (segment.type) {
case 'text':
return renderTextSegment(segment.data as TextSegmentData, isMe);
case 'image':
return renderImageSegment(segment.data as ImageSegmentData, props);
case 'at':
return renderAtSegment(segment.data as AtSegmentData, props);
case 'reply':
return null; // reply segment 单独处理
case 'face':
return renderFaceSegment(segment.data as FaceSegmentData, isMe);
case 'voice':
return renderVoiceSegment(segment.data as VoiceSegmentData, isMe);
case 'video':
return renderVideoSegment(segment.data as VideoSegmentData, isMe);
case 'file':
return renderFileSegment(segment.data as FileSegmentData, isMe);
case 'link':
return renderLinkSegment(segment.data as LinkSegmentData, props);
default:
return null;
}
};
/**
* 渲染文本 Segment
*/
const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => {
// 兼容 content 和 text 两种字段
const text = data.content ?? data.text ?? '';
return (
<Text
key={`text-${text.substring(0, 10)}`}
style={[styles.textContent, isMe ? styles.textMe : styles.textOther]}
selectable={false}
>
{text}
</Text>
);
};
/**
* 渲染图片 Segment
*/
// 图片Segment组件 - 使用Hook获取实际尺寸
const ImageSegment: React.FC<{
data: ImageSegmentData;
isMe: boolean;
onImagePress?: (url: string) => void;
onImageLongPress?: (position?: { x: number; y: number }) => void;
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
const pressPositionRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const imageUrl = data.thumbnail_url || data.url;
useEffect(() => {
// 如果已经有宽高信息,直接使用
if (data.width && data.height) {
const aspectRatio = data.width / data.height;
let width = data.width;
let height = data.height;
if (width > IMAGE_MAX_WIDTH) {
width = IMAGE_MAX_WIDTH;
height = width / aspectRatio;
}
if (height > IMAGE_MAX_HEIGHT) {
height = IMAGE_MAX_HEIGHT;
width = height * aspectRatio;
}
setDimensions({ width: Math.round(width), height: Math.round(height) });
return;
}
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => {
if (!dimensions) {
setDimensions(IMAGE_FALLBACK_SIZE);
}
}, 3000);
// 否则从图片获取实际尺寸
Image.getSize(
imageUrl,
(width, height) => {
clearTimeout(timeoutId);
const aspectRatio = width / height;
let newWidth = width;
let newHeight = height;
if (newWidth > IMAGE_MAX_WIDTH) {
newWidth = IMAGE_MAX_WIDTH;
newHeight = newWidth / aspectRatio;
}
if (newHeight > IMAGE_MAX_HEIGHT) {
newHeight = IMAGE_MAX_HEIGHT;
newWidth = newHeight * aspectRatio;
}
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
},
(error) => {
clearTimeout(timeoutId);
// 获取失败时使用默认 4:3 比例
setDimensions(IMAGE_FALLBACK_SIZE);
}
);
return () => clearTimeout(timeoutId);
}, [imageUrl, data.width, data.height]);
// 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
pressPositionRef.current = { x: pageX, y: pageY };
};
// 处理长按
const handleLongPress = () => {
onImageLongPress?.(pressPositionRef.current);
};
// 初始加载时显示占位
if (!dimensions) {
return (
<TouchableOpacity
key={`image-${data.url}`}
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
onPressIn={handlePressIn}
onPress={() => onImagePress?.(data.url)}
onLongPress={handleLongPress}
delayLongPress={500}
activeOpacity={0.9}
>
<View
style={{
width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
}}
/>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
key={`image-${data.url}`}
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
onPressIn={handlePressIn}
onPress={() => onImagePress?.(data.url)}
onLongPress={handleLongPress}
delayLongPress={500}
activeOpacity={0.9}
>
<ExpoImage
source={{ uri: imageUrl }}
style={{
width: dimensions.width,
height: dimensions.height,
borderRadius: 8,
}}
contentFit="cover"
cachePolicy="disk"
priority="normal"
/>
</TouchableOpacity>
);
};
const renderImageSegment = (
data: ImageSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { onImagePress, onImageLongPress, isMe } = props;
return <ImageSegment data={data} isMe={isMe} onImagePress={onImagePress} onImageLongPress={onImageLongPress} />;
};
/**
* 渲染 @ Segment
*/
const renderAtSegment = (
data: AtSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { currentUserId, memberMap, onAtPress, isMe } = props;
// 判断是否@当前用户
const isAtMe = data.user_id === currentUserId || data.user_id === 'all';
// 优先从 memberMap 中查找昵称,兜底使用 data.nickname兼容旧消息再兜底显示"用户"
let displayName: string;
if (data.user_id === 'all') {
displayName = '所有人';
} else {
const member = memberMap?.get(data.user_id);
displayName = member?.nickname || data.nickname || '用户';
}
return (
<Text
key={`at-${data.user_id}`}
style={[
styles.atText,
isMe ? styles.atTextMe : styles.atTextOther,
isAtMe && styles.atHighlight,
]}
onPress={() => data.user_id !== 'all' && onAtPress?.(data.user_id)}
>
@{displayName}
</Text>
);
};
/**
* 渲染表情 Segment
*/
const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => {
// 如果有自定义表情URL显示图片
if (data.url) {
return (
<ExpoImage
key={`face-${data.id}`}
source={{ uri: data.url }}
style={styles.faceImage}
contentFit="cover"
cachePolicy="memory"
/>
);
}
// 否则显示表情名称(后续可以映射到本地表情)
return (
<Text key={`face-${data.id}`} style={styles.faceText}>
[{data.name || `表情${data.id}`}]
</Text>
);
};
/**
* 渲染语音 Segment
*/
const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => {
return (
<TouchableOpacity
key={`voice-${data.url}`}
style={[styles.voiceContainer, isMe ? styles.voiceMe : styles.voiceOther]}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="microphone"
size={20}
color={isMe ? '#FFFFFF' : '#666666'}
/>
<Text style={[styles.voiceDuration, isMe ? styles.textMe : styles.textOther]}>
{data.duration ? `${data.duration}"` : '语音'}
</Text>
</TouchableOpacity>
);
};
/**
* 视频 Segment 组件 - 支持点击播放
*/
const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const [playerVisible, setPlayerVisible] = useState(false);
const handlePress = useCallback(() => {
if (data.url) {
setPlayerVisible(true);
}
}, [data.url]);
return (
<>
<TouchableOpacity
style={[styles.videoContainer, isMe ? styles.videoMe : styles.videoOther]}
onPress={handlePress}
activeOpacity={0.7}
>
{data.thumbnail_url ? (
<ExpoImage
source={{ uri: data.thumbnail_url }}
style={styles.videoThumbnail}
contentFit="cover"
/>
) : (
<View style={styles.videoPlaceholder}>
<MaterialCommunityIcons name="video" size={32} color="#FFFFFF" />
</View>
)}
<View style={styles.videoPlayIcon}>
<MaterialCommunityIcons name="play-circle" size={40} color="#FFFFFF" />
</View>
{data.duration && (
<Text style={styles.videoDuration}>{formatDuration(data.duration)}</Text>
)}
</TouchableOpacity>
<VideoPlayerModal
visible={playerVisible}
url={data.url}
onClose={() => setPlayerVisible(false)}
/>
</>
);
};
const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactNode => {
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
};
/**
* 渲染文件 Segment
*/
const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => {
const fileSize = data.size ? formatFileSize(data.size) : '';
return (
<TouchableOpacity
key={`file-${data.url}`}
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
activeOpacity={0.7}
>
<View style={styles.fileIcon}>
<MaterialCommunityIcons
name="file-document"
size={28}
color={isMe ? '#FFFFFF' : colors.primary.main}
/>
</View>
<View style={styles.fileInfo}>
<Text
style={[styles.fileName, isMe ? styles.textMe : styles.textOther]}
numberOfLines={1}
>
{data.name}
</Text>
{fileSize && (
<Text style={styles.fileSize}>{fileSize}</Text>
)}
</View>
</TouchableOpacity>
);
};
/**
* 渲染链接 Segment
*/
const renderLinkSegment = (
data: LinkSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { onLinkPress, isMe } = props;
const handlePress = () => {
if (onLinkPress) {
onLinkPress(data.url);
} else {
Linking.openURL(data.url).catch(() => {});
}
};
return (
<TouchableOpacity
key={`link-${data.url}`}
style={[styles.linkContainer, isMe ? styles.linkMe : styles.linkOther]}
onPress={handlePress}
activeOpacity={0.7}
>
{data.image && (
<ExpoImage source={{ uri: data.image }} style={styles.linkImage} contentFit="cover" cachePolicy="memory" />
)}
<View style={styles.linkContent}>
<Text style={styles.linkTitle} numberOfLines={2}>
{data.title || data.url}
</Text>
{data.description && (
<Text style={styles.linkDescription} numberOfLines={2}>
{data.description}
</Text>
)}
<Text style={styles.linkUrl} numberOfLines={1}>
{new URL(data.url).hostname}
</Text>
</View>
</TouchableOpacity>
);
};
/**
* 回复预览组件(用于消息气泡内显示回复引用)
*/
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
replyData,
replyMessage,
isMe,
onPress,
getSenderInfo,
}) => {
if (!replyMessage) {
// 如果没有引用消息详情只显示引用ID
return (
<TouchableOpacity
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.replyLine} />
<Text style={styles.replyText} numberOfLines={2}>
</Text>
</TouchableOpacity>
);
}
// 获取发送者信息 - 优先使用 replyMessage.sender
let senderName = '';
if (replyMessage.sender?.nickname) {
senderName = replyMessage.sender.nickname;
} else if (replyMessage.sender?.username) {
senderName = replyMessage.sender.username;
} else if (getSenderInfo && replyMessage.sender_id) {
const info = getSenderInfo(replyMessage.sender_id);
senderName = info.nickname;
}
// 如果还是没有昵称,显示"对方"
if (!senderName || senderName === '用户') {
senderName = '对方';
}
// 获取预览内容 - 使用 extractTextFromSegments 从 segments 提取
let previewContent = '';
if (replyMessage.segments?.length) {
// 检查是否有图片/语音/视频/文件
const hasImage = replyMessage.segments.some(s => s.type === 'image');
const hasVoice = replyMessage.segments.some(s => s.type === 'voice');
const hasVideo = replyMessage.segments.some(s => s.type === 'video');
const hasFile = replyMessage.segments.some(s => s.type === 'file');
if (hasImage) {
previewContent = '[图片]';
} else if (hasVoice) {
previewContent = '[语音]';
} else if (hasVideo) {
previewContent = '[视频]';
} else if (hasFile) {
previewContent = '[文件]';
} else {
// 从文本 segments 提取
previewContent = extractTextFromSegments(replyMessage.segments);
}
}
return (
<TouchableOpacity
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.replyContent}>
<Text style={styles.replySender} numberOfLines={1}>
{senderName}:
</Text>
<Text
style={styles.replyText}
numberOfLines={1}
ellipsizeMode="tail"
>
{previewContent}
</Text>
</View>
</TouchableOpacity>
);
};
/**
* 渲染完整的消息链(多个 Segment 组合)
*/
export const MessageSegmentsRenderer: React.FC<{
segments: MessageSegment[];
isMe: boolean;
currentUserId?: string;
memberMap?: Map<string, { nickname: string; user_id: string }>;
replyMessage?: MessageResponse;
onAtPress?: (userId: string) => void;
onReplyPress?: (messageId: string) => void;
onImagePress?: (url: string) => void;
onImageLongPress?: () => void;
onLinkPress?: (url: string) => void;
getSenderInfo?: (senderId: string) => SenderInfo;
}> = ({
segments,
isMe,
currentUserId,
memberMap,
replyMessage,
onAtPress,
onReplyPress,
onImagePress,
onImageLongPress,
onLinkPress,
getSenderInfo,
}) => {
// 找出 reply segment如果有
const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply');
const renderProps = {
isMe,
currentUserId,
memberMap,
onAtPress,
onReplyPress,
onImagePress,
onImageLongPress,
onLinkPress,
};
return (
<View style={styles.segmentsContainer}>
{/* 回复预览 */}
{replySegment && (
<ReplyPreviewSegment
replyData={(replySegment.data as ReplySegmentData)}
replyMessage={replyMessage}
isMe={isMe}
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
getSenderInfo={getSenderInfo}
/>
)}
{/* 其他 Segment 内容 */}
<View style={styles.segmentsContent}>
{otherSegments.map((segment, index) => (
<React.Fragment key={`segment-${index}-${segment.type}`}>
{renderSegment(segment, renderProps)}
</React.Fragment>
))}
</View>
</View>
);
};
// 辅助函数:格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
// 辅助函数:格式化时长
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
};
const styles = StyleSheet.create({
// 容器
segmentsContainer: {
flexDirection: 'column',
width: '100%',
},
segmentsContent: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-end',
},
// 文本 - Telegram风格统一深色字体
textContent: {
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
},
textMe: {
color: '#1A1A1A', // 统一使用深色字体
},
textOther: {
color: '#1A1A1A', // 统一使用深色字体
},
// @提及 - Telegram风格蓝色高亮
atText: {
fontSize: 16,
lineHeight: 23,
fontWeight: '600',
paddingHorizontal: 2,
},
atTextMe: {
color: '#1976D2', // 蓝色
backgroundColor: 'rgba(25, 118, 210, 0.12)',
borderRadius: 4,
},
atTextOther: {
color: '#1976D2', // 蓝色
backgroundColor: 'rgba(25, 118, 210, 0.12)',
borderRadius: 4,
},
atHighlight: {
backgroundColor: 'rgba(25, 118, 210, 0.2)',
borderRadius: 4,
paddingHorizontal: 4,
},
// 图片 - QQ风格保持原始宽高比
imageContainer: {
borderRadius: 12,
overflow: 'hidden',
marginTop: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
imageMe: {
borderBottomRightRadius: 4,
},
imageOther: {
borderBottomLeftRadius: 4,
},
// 移除固定的 image 样式,改为在组件中动态计算
// 表情 - QQ风格更大的表情
faceImage: {
width: 28,
height: 28,
},
faceText: {
fontSize: 16,
color: '#666',
},
// 语音 - QQ风格更明显的背景
voiceContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderRadius: 20,
minWidth: 100,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
voiceMe: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
voiceOther: {
backgroundColor: 'rgba(0, 0, 0, 0.06)',
},
voiceDuration: {
marginLeft: spacing.sm,
fontSize: 15,
fontWeight: '500',
},
// 视频 - QQ风格更大的缩略图
videoContainer: {
borderRadius: 16,
overflow: 'hidden',
width: 220,
height: 165,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
videoMe: {
borderBottomRightRadius: 6,
},
videoOther: {
borderBottomLeftRadius: 6,
},
videoThumbnail: {
width: '100%',
height: '100%',
},
videoPlaceholder: {
width: '100%',
height: '100%',
backgroundColor: 'linear-gradient(135deg, #333 0%, #555 100%)',
justifyContent: 'center',
alignItems: 'center',
},
videoPlayIcon: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -24,
marginLeft: -24,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
borderRadius: 24,
padding: 4,
},
videoDuration: {
position: 'absolute',
bottom: 10,
right: 10,
color: '#FFFFFF',
fontSize: 13,
fontWeight: '600',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
overflow: 'hidden',
},
// 文件 - QQ风格卡片式设计
fileContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderRadius: 16,
minWidth: 220,
maxWidth: 300,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 2,
},
fileMe: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
fileOther: {
backgroundColor: '#F8F9FA',
},
fileIcon: {
marginRight: spacing.md,
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 15,
fontWeight: '600',
color: '#1A1A1A',
},
fileSize: {
fontSize: 13,
color: '#8E8E93',
marginTop: 4,
fontWeight: '500',
},
// 链接 - QQ风格卡片式设计
linkContainer: {
borderRadius: 16,
overflow: 'hidden',
maxWidth: 280,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
linkMe: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
},
linkOther: {
backgroundColor: '#FFFFFF',
},
linkImage: {
width: '100%',
height: 140,
},
linkContent: {
padding: spacing.md,
},
linkTitle: {
fontSize: 15,
fontWeight: '600',
color: '#1A1A1A',
lineHeight: 20,
},
linkDescription: {
fontSize: 13,
color: '#666666',
marginTop: 6,
lineHeight: 18,
},
linkUrl: {
fontSize: 12,
color: '#8E8E93',
marginTop: 6,
fontWeight: '500',
},
// 回复预览 - Telegram风格简洁设计
replyPreview: {
flexDirection: 'row',
marginBottom: 0,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm + 2,
borderRadius: 6,
width: '100%',
minWidth: 200,
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
replyMe: {
backgroundColor: 'rgba(25, 118, 210, 0.1)',
},
replyOther: {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
replyLine: {
width: 0,
marginRight: 0,
},
replyContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'nowrap',
},
replySender: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
marginRight: 6,
flexShrink: 0,
},
replyText: {
fontSize: 13,
color: '#666666',
flex: 1,
flexShrink: 1,
},
});
export default MessageSegmentsRenderer;

View File

@@ -0,0 +1,169 @@
/**
* ChatScreen 常量定义
*/
import { MoreAction } from './types';
// 常用表情列表
export const EMOJIS = [
// 笑脸和情感
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
'🤨', '😐', '😑', '😶', '😶‍🌫️', '😏', '😒', '🙄',
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
'😵', '😵‍💫', '🤯', '🤠', '🥳', '🥸', '😎', '🤓',
'🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲',
'😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢',
'😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫',
'🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀',
'☠️', '💩', '🤡', '👹', '👺', '👻', '👽', '👾',
'🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀',
'😿', '😾', '🙈', '🙉', '🙊', '💋', '💌', '💘',
// 人物和手势
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
'🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛',
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
'💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂',
'🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀',
'👁️', '👅', '👄', '👶', '🧒', '👦', '👧', '🧑',
'👨', '👩', '🧓', '👴', '👵', '👸', '🤴', '👰',
// 动物
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
'🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🙈',
'🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅',
'🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛',
'🦋', '🐌', '🐞', '🐜', '🦟', '🦗', '🕷️', '🦂',
'🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐',
'🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋',
'🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🦧', '🐘',
'🦛', '🦏', '🐪', '🐫', '🦒', '🦘', '🦬', '🐃',
'🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🦙', '🐐',
'🦌', '🐕', '🐩', '🦮', '🐈', '🐈‍⬛', '🐓', '🦃',
'🦚', '🦜', '🦢', '🦩', '🕊️', '🐕‍🦺', '🐰', '🐻‍❄️',
// 食物和饮料
'🍎', '🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇',
'🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥',
'🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️',
'🫑', '🌽', '🥕', '🫒', '🧄', '🧅', '🥔', '🍠',
'🥐', '🥯', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳',
'🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴',
'🌭', '🍔', '🍟', '🍕', '🫓', '🥪', '🥙', '🧆',
'🌮', '🌯', '🫔', '🥗', '🥘', '🫕', '🍝', '🍜',
'🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙',
'🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧',
'🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭',
'🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯',
'🥛', '🍼', '☕', '🫖', '🍵', '🍶', '🍾', '🍷',
'🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋',
'🧃', '🧉', '🧊', '🥢', '🍽️', '🍴', '🥄', '🔪',
// 自然和天气
'🌸', '💮', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼',
'🌷', '🌱', '🪴', '🌲', '🌳', '🌴', '🌵', '🌾',
'🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰',
'🌍', '🌎', '🌏', '🌐', '🗺️', '🗾', '🧭', '🏔️',
'⛰️', '🌋', '🗻', '🏕️', '🏖️', '🏜️', '🏝️', '🏞️',
'🏟️', '🏛️', '🏗️', '🧱', '🏘️', '🏚️', '🏠', '🏢',
'🌚', '🌕', '🌔', '🌓', '🌒', '🌑', '🌙', '🌎',
'⭐', '🌟', '🌠', '☁️', '⛅', '⛈️', '🌤️', '🌥️',
'🌦️', '🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '🌀',
'🌈', '🌂', '☔', '⚡', '❄️', '☃️', '⛄', '☄️',
'🔥', '💧', '🌊', '💨', '☀️', '🌙', '⭐', '✨',
// 心形和符号
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗',
'💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️',
'☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎',
'🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶',
'🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐',
'㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️',
'🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔',
'📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳',
'🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔',
'‼️', '⁉️', '🔔', '🔕', '🎵', '🎶', '➡️', '⬅️',
'⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️',
'↩️', '↪️', '⤴️', '⤵️', '🔃', '🔄', '🔙', '🔚',
'🔛', '🔜', '🔝', '🛐', '⚛️', '🕉️', '✡️', '☸️',
// 庆祝和物品
'🎉', '🎊', '🎈', '🎏', '🎀', '🎗️', '🎟️', '🎫',
'🎖️', '🏆', '🏅', '🥇', '🥈', '🥉', '⚽', '⚾',
'🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏', '🎳',
'🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋',
'🥅', '⛳', '⛸️', '🎣', '🤿', '🎽', '🎿', '🛷',
'🥌', '🎯', '🪀', '🪁', '🎱', '🔮', '🧿', '🎮',
'🕹️', '🎰', '🎲', '🧩', '🧸', '🪅', '🪆', '♠️',
'♥️', '♦️', '♣️', '♟️', '🃏', '🀄', '🎴', '🎭',
'🖼️', '🎨', '🧵', '🪡', '🧶', '🪢', '👓', '🕶️',
'🥽', '🥼', '🦺', '👔', '👕', '👖', '🧣', '🧤',
'🧥', '🧦', '👗', '👘', '🥻', '🩱', '🩲', '🩳',
'👙', '👚', '👛', '👜', '👝', '🛍️', '🎒', '🩴',
'👞', '👟', '🥾', '🥿', '👠', '👡', '👢', '🩰',
'👑', '👒', '🎩', '🎓', '🧢', '🪖', '⛑️', '📿',
'💄', '💍', '💎', '🔇', '🔈', '🔉', '🔊', '📢',
'📣', '📯', '🔔', '🔕', '🎼', '🎵', '🎶', '🎙️',
'🎚️', '🎛️', '🎤', '🎧', '📻', '🎷', '🎸', '🎹',
'🎺', '🎻', '🪕', '🥁', '🪘', '📱', '📲', '☎️',
'📞', '📟', '📠', '🔋', '🔌', '💻', '🖥️', '🖨️',
'⌨️', '🖱️', '🖲️', '💽', '💾', '💿', '📀', '🧮',
// 灯光和标志
'💡', '🔦', '🏮', '🪔', '📔', '📕', '📖', '📗',
'📘', '📙', '📚', '📓', '📒', '📃', '📜', '📄',
'📰', '🗞️', '📑', '🔖', '🏷️', '💰', '💴', '💵',
'💶', '💷', '💸', '💳', '🧾', '💹', '✉️', '📧',
'📨', '📩', '📤', '📥', '📦', '📫', '📪', '📬',
'📭', '📮', '🗳️', '✏️', '✒️', '🖋️', '🖊️', '🖌️',
'🖍️', '📝', '💼', '📁', '📂', '🗂️', '📅', '📆',
'🗒️', '🗓️', '📇', '📈', '📉', '📊', '📋', '📌',
'📍', '📎', '🖇️', '📏', '📐', '✂️', '🗃️', '🗄️',
'🗑️', '🔒', '🔓', '🔏', '🔐', '🔑', '🗝️', '🔨',
'🪓', '⛏️', '⚒️', '🛠️', '🗡️', '⚔️', '🔫', '🪃',
'🏹', '🛡️', '🪚', '🔧', '🪛', '🔩', '⚙️', '🗜️',
'⚖️', '🦯', '🔗', '⛓️', '🪝', '🧰', '🧲', '🪜',
// 交通和旅行
'🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑',
'🚒', '🚐', '🚚', '🚛', '🚜', '🦯', '🦽', '🦼',
'🛴', '🚲', '🛵', '🏍️', '🛺', '🚨', '🚔', '🚍',
'🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋', '🚞',
'🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊',
'🚉', '✈️', '🛫', '🛬', '🛩️', '💺', '🛰️', '🚀',
'🛸', '🚁', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️',
'🚢', '⚓', '⛽', '🚧', '🚦', '🚥', '🚏', '🗺️',
'🗿', '🗽', '🗼', '🏰', '🏯', '🏟️', '🎡', '🎢',
'🎠', '⛲', '⛱️', '🏖️', '🏝️', '🏜️', '🌋', '⛰️',
'🏔️', '🗻', '🏕️', '⛺', '🏠', '🏡', '🏘️', '🏚️',
'🏗️', '🏭', '🏢', '🏬', '🏣', '🏤', '🏥', '🏦',
'🏨', '🏪', '🏫', '🏩', '🏛️', '⛪', '🕌', '🕍',
'🛕', '🕋', '⛩️', '🛤️', '🛣️', '🗾', '🎑', '🏞️',
'🌅', '🌄', '🌠', '🎇', '🎆', '🌇', '🌆', '🏙️',
'🌃', '🌌', '🌉', '🌫️', '🌊', '🕳️', '💊', '💉',
];
// 更多功能项
export const MORE_ACTIONS: MoreAction[] = [
{ id: 'image', icon: 'image', name: '图片', color: '#FF6B6B' },
{ id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' },
{ id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' },
{ id: 'location', icon: 'map-marker', name: '位置', color: '#96CEB4' },
];
// 消息撤回时间限制(毫秒)
export const RECALL_TIME_LIMIT = 2 * 60 * 1000; // 2分钟
// 时间分隔间隔(毫秒)
export const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000; // 5分钟
// 面板高度
export const PANEL_HEIGHTS = {
mention: 250,
emoji: 350,
more: 350,
};
// 输入框最大长度
export const MAX_INPUT_LENGTH = 500;
// 群成员加载每页数量
export const MEMBERS_PAGE_SIZE = 100;

View File

@@ -0,0 +1,32 @@
/**
* ChatScreen 组件导出
*/
// 类型
export * from './types';
// 常量
export * from './constants';
// 样式
export { chatScreenStyles } from './styles';
// 组件
export { EmojiPanel } from './EmojiPanel';
export { MorePanel } from './MorePanel';
export { MentionPanel } from './MentionPanel';
export { LongPressMenu } from './LongPressMenu';
export { ChatHeader } from './ChatHeader';
export { MessageBubble } from './MessageBubble';
export { ChatInput } from './ChatInput';
// Segment 渲染组件
export {
MessageSegmentsRenderer,
ReplyPreviewSegment,
renderSegment
} from './SegmentRenderer';
export type { SegmentRendererProps, ReplyPreviewSegmentProps } from './SegmentRenderer';
// Hook
export { useChatScreen } from './useChatScreen';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,213 @@
/**
* ChatScreen 类型定义
*/
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
// 面板类型
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
// 消息状态
export type MessageStatus = 'normal' | 'recalled' | 'deleted';
// 用户角色
export type UserRole = 'owner' | 'admin' | 'member';
// 扩展消息类型以支持群聊
export interface GroupMessage extends MessageResponse {
sender?: UserDTO;
mention_users?: string[];
mention_all?: boolean;
is_system_notice?: boolean;
notice_content?: string;
// category 继承自 MessageResponse用于判断消息类别
// 'chat' - 普通聊天消息
// 'notification' - 通知类消息(如禁言通知)
// 'announcement' - 系统公告
}
// 路由参数
export interface ChatRouteParams {
conversationId?: string;
userId?: string;
isGroupChat?: boolean;
groupId?: string;
groupName?: string;
}
// 发送者信息
export interface SenderInfo {
nickname: string;
avatar: string | null;
userId?: string;
}
// 更多功能项
export interface MoreAction {
id: string;
icon: string;
name: string;
color: string;
}
// 长按菜单项
export interface MenuItem {
id: string;
label: string;
icon: string;
color?: string;
onPress: () => void;
}
// ChatScreen Props
export interface ChatScreenProps {
// 路由参数通过 useRoute 获取
}
// 消息气泡 Props
export interface MessageBubbleProps {
message: GroupMessage;
index: number;
currentUserId: string;
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
isGroupChat: boolean;
groupMembers: GroupMemberResponse[];
/** @deprecated 发送者信息现在直接从 message.sender 获取,不再使用 senderCache */
senderCache?: Map<string, UserDTO>;
otherUserLastReadSeq: number;
selectedMessageId: string | null;
// 消息映射,用于查找被回复的消息
messageMap?: Map<string, GroupMessage>;
onLongPress: (message: GroupMessage, position?: MenuPosition) => void;
onAvatarPress: (userId: string) => void;
onAvatarLongPress: (senderId: string, nickname: string) => void;
formatTime: (dateString: string) => string;
shouldShowTime: (index: number) => boolean;
// 图片点击回调
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
}
// 输入框 Props
export interface ChatInputProps {
inputText: string;
onInputChange: (text: string) => void;
onSend: () => void;
onToggleEmoji: () => void;
onToggleMore: () => void;
activePanel: PanelType;
sending: boolean;
isMuted: boolean;
isGroupChat: boolean;
muteAll: boolean;
replyingTo: GroupMessage | null;
onCancelReply: () => void;
onFocus: () => void;
restrictionHint?: string | null;
disableMore?: boolean;
}
// 表情面板 Props
export interface EmojiPanelProps {
onInsertEmoji: (emoji: string) => void;
onClose: () => void;
}
// 更多功能面板 Props
export interface MorePanelProps {
onAction: (actionId: string) => void;
disabledActionIds?: string[];
}
// @成员选择面板 Props
export interface MentionPanelProps {
members: GroupMemberResponse[];
currentUserId: string;
mentionQuery: string;
currentUserRole: UserRole;
onSelectMention: (member: GroupMemberResponse) => void;
onMentionAll: () => void;
onClose: () => void;
}
// 菜单位置信息
export interface MenuPosition {
x: number;
y: number;
width: number;
height: number;
pressX?: number; // 按压点X坐标
pressY?: number; // 按压点Y坐标
}
// 长按菜单 Props
export interface LongPressMenuProps {
visible: boolean;
message: GroupMessage | null;
currentUserId: string;
position?: MenuPosition;
onClose: () => void;
onReply: (message: GroupMessage) => void;
onRecall: (messageId: string) => void;
onDelete: (messageId: string) => void;
onAddSticker?: (imageUrl: string) => void;
}
// 聊天头部 Props
export interface ChatHeaderProps {
isGroupChat: boolean;
groupInfo: { name?: string; member_count?: number } | null;
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
routeGroupName?: string;
typingHint: string | null;
onBack: () => void;
onTitlePress: () => void;
onMorePress: () => void;
}
// 回复预览 Props
export interface ReplyPreviewProps {
replyingTo: GroupMessage | null;
currentUserId: string;
currentUser: UserDTO | null;
otherUser: UserDTO | null;
isGroupChat: boolean;
getSenderInfo: (senderId: string) => SenderInfo;
onCancel: () => void;
}
// ChatScreen 状态接口
export interface ChatScreenState {
// 基础状态
messages: GroupMessage[];
conversationId: string | null;
inputText: string;
otherUser: UserDTO | null;
currentUser: UserDTO | null;
keyboardHeight: number;
loading: boolean;
sending: boolean;
currentUserId: string;
lastSeq: number;
otherUserLastReadSeq: number;
activePanel: PanelType;
sendingImage: boolean;
// 回复消息状态
replyingTo: GroupMessage | null;
// 长按菜单状态
longPressMenuVisible: boolean;
selectedMessage: GroupMessage | null;
// 群聊相关状态
groupInfo: GroupResponse | null;
groupMembers: GroupMemberResponse[];
typingUsers: string[];
currentUserRole: UserRole;
mentionQuery: string;
selectedMentions: string[];
mentionAll: boolean;
isMuted: boolean;
muteAll: boolean;
}

File diff suppressed because it is too large Load Diff