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:
376
src/screens/message/ChatScreen.tsx
Normal file
376
src/screens/message/ChatScreen.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 聊天页 ChatScreen
|
||||
* 胡萝卜BBS - 私信/群聊聊天界面
|
||||
* 高级现代化设计
|
||||
* 支持群聊功能:显示发送者头像和昵称、@提及功能
|
||||
* 支持响应式布局(桌面端宽屏优化)
|
||||
*
|
||||
* 重构说明:将原2264行的大文件拆分为多个模块化组件
|
||||
* - types.ts: 类型定义
|
||||
* - constants.ts: 常量定义
|
||||
* - styles.ts: 样式定义
|
||||
* - useChatScreen.ts: 自定义Hook,管理所有状态和逻辑
|
||||
* - EmojiPanel.tsx: 表情面板组件
|
||||
* - MorePanel.tsx: 更多功能面板组件
|
||||
* - MentionPanel.tsx: @成员选择面板组件
|
||||
* - LongPressMenu.tsx: 长按菜单组件
|
||||
* - ChatHeader.tsx: 聊天头部组件
|
||||
* - MessageBubble.tsx: 消息气泡组件
|
||||
* - ChatInput.tsx: 输入框组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { colors } from '../../theme';
|
||||
import { messageManager } from '../../stores';
|
||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
useChatScreen,
|
||||
chatScreenStyles as baseStyles,
|
||||
EmojiPanel,
|
||||
MorePanel,
|
||||
MentionPanel,
|
||||
LongPressMenu,
|
||||
ChatHeader,
|
||||
MessageBubble,
|
||||
ChatInput,
|
||||
PANEL_HEIGHTS,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
|
||||
const containerStyle = useMemo(() => ([
|
||||
styles.container,
|
||||
isWideScreen ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.container]);
|
||||
|
||||
const listContentStyle = useMemo(() => ([
|
||||
styles.listContent,
|
||||
isWideScreen
|
||||
? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const }
|
||||
: { paddingHorizontal: 16 },
|
||||
]), [isWideScreen, styles.listContent]);
|
||||
|
||||
const inputWrapperStyle = useMemo(() => ([
|
||||
styles.inputWrapper,
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputWrapper]);
|
||||
|
||||
// 图片查看器状态
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
// 图片点击处理函数
|
||||
const handleImagePress = (images: ImageGridItem[], index: number) => {
|
||||
setChatImages(images);
|
||||
setSelectedImageIndex(index);
|
||||
setShowImageViewer(true);
|
||||
};
|
||||
|
||||
// 关闭图片查看器
|
||||
const handleCloseImageViewer = () => {
|
||||
setShowImageViewer(false);
|
||||
};
|
||||
|
||||
const {
|
||||
// 状态
|
||||
messages,
|
||||
inputText,
|
||||
otherUser,
|
||||
currentUser,
|
||||
currentUserId,
|
||||
keyboardHeight,
|
||||
loading,
|
||||
sending,
|
||||
activePanel,
|
||||
sendingImage,
|
||||
replyingTo,
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
groupMembers,
|
||||
currentUserRole,
|
||||
mentionQuery,
|
||||
isMuted,
|
||||
muteAll,
|
||||
followRestrictionHint,
|
||||
canSendPrivateImage,
|
||||
routeGroupId,
|
||||
routeGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
|
||||
// Refs
|
||||
flatListRef,
|
||||
textInputRef,
|
||||
scrollPositionRef,
|
||||
|
||||
// 方法
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
handleInputChange,
|
||||
handleSend,
|
||||
handleMoreAction,
|
||||
handleInsertEmoji,
|
||||
handleSendSticker,
|
||||
toggleEmojiPanel,
|
||||
toggleMorePanel,
|
||||
closePanel,
|
||||
handleRecall,
|
||||
handleLongPressMessage,
|
||||
hideLongPressMenu,
|
||||
handleDeleteMessage,
|
||||
handleReplyMessage,
|
||||
handleCancelReply,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
handleSelectMention,
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getInputBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
loadMoreHistory,
|
||||
handleMessageListContentSizeChange,
|
||||
} = useChatScreen();
|
||||
|
||||
// 监听返回事件,刷新会话列表
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||
// 刷新会话列表,确保已读状态正确显示
|
||||
messageManager.fetchConversations(true);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [navigation]);
|
||||
|
||||
// 渲染消息气泡
|
||||
const renderMessage = ({ item, index }: { item: any; index: number }) => (
|
||||
<MessageBubble
|
||||
message={item}
|
||||
index={index}
|
||||
currentUserId={currentUserId}
|
||||
currentUser={currentUser}
|
||||
otherUser={otherUser}
|
||||
isGroupChat={isGroupChat}
|
||||
groupMembers={groupMembers}
|
||||
otherUserLastReadSeq={otherUserLastReadSeq}
|
||||
selectedMessageId={selectedMessageId}
|
||||
messageMap={messageMap}
|
||||
onLongPress={handleLongPressMessage}
|
||||
onAvatarPress={handleAvatarPress}
|
||||
onAvatarLongPress={handleAvatarLongPress}
|
||||
formatTime={formatTime}
|
||||
shouldShowTime={shouldShowTime}
|
||||
onImagePress={handleImagePress}
|
||||
/>
|
||||
);
|
||||
|
||||
// 获取正在输入提示
|
||||
const typingHint = getTypingHint();
|
||||
|
||||
const handleMessageListScroll = useCallback((event: any) => {
|
||||
const { contentSize, contentOffset } = event.nativeEvent;
|
||||
scrollPositionRef.current = {
|
||||
contentHeight: contentSize.height,
|
||||
scrollY: contentOffset.y,
|
||||
};
|
||||
}, [scrollPositionRef]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={containerStyle}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<StatusBar style="dark" backgroundColor="#FFFFFF" />
|
||||
|
||||
{/* 顶部栏 */}
|
||||
<ChatHeader
|
||||
isGroupChat={isGroupChat}
|
||||
groupInfo={groupInfo}
|
||||
otherUser={otherUser}
|
||||
routeGroupName={routeGroupName}
|
||||
typingHint={typingHint}
|
||||
onBack={() => navigation.goBack()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<TouchableWithoutFeedback onPress={handleDismiss}>
|
||||
<View style={styles.messageListContainer}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => String(item.id)}
|
||||
contentContainerStyle={listContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshing={loadingMore}
|
||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
||||
progressViewOffset={0}
|
||||
onContentSizeChange={handleMessageListContentSizeChange}
|
||||
onScroll={handleMessageListScroll}
|
||||
scrollEventThrottle={16}
|
||||
// 优化渲染性能
|
||||
initialNumToRender={15}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{/* 底部区域:输入框 + 面板 */}
|
||||
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>
|
||||
{/* 输入框区域 */}
|
||||
<View
|
||||
style={[inputWrapperStyle, insets.bottom > 0 && { paddingBottom: insets.bottom }]}
|
||||
onLayout={e => setInputWrapperHeight(e.nativeEvent.layout.height)}
|
||||
>
|
||||
<ChatInput
|
||||
inputText={inputText}
|
||||
onInputChange={handleInputChange}
|
||||
onSend={handleSend}
|
||||
onToggleEmoji={toggleEmojiPanel}
|
||||
onToggleMore={toggleMorePanel}
|
||||
activePanel={activePanel}
|
||||
sending={sending}
|
||||
isMuted={isMuted}
|
||||
isGroupChat={isGroupChat}
|
||||
muteAll={muteAll}
|
||||
restrictionHint={followRestrictionHint}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={handleCancelReply}
|
||||
onFocus={() => {
|
||||
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
currentUser={currentUser}
|
||||
otherUser={otherUser}
|
||||
getSenderInfo={getSenderInfo}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 表情面板 */}
|
||||
{activePanel === 'emoji' && keyboardHeight === 0 && (
|
||||
<View style={[styles.panelWrapper, styles.emojiPanelWrapper, { height: PANEL_HEIGHTS.emoji + insets.bottom, paddingBottom: insets.bottom }]}>
|
||||
<EmojiPanel
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 更多功能面板 */}
|
||||
{activePanel === 'more' && keyboardHeight === 0 && (
|
||||
<View style={[styles.panelWrapper, { height: PANEL_HEIGHTS.more + insets.bottom, paddingBottom: insets.bottom }]}>
|
||||
<MorePanel
|
||||
onAction={handleMoreAction}
|
||||
disabledActionIds={!isGroupChat && !canSendPrivateImage ? ['image', 'camera'] : []}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 发送图片加载遮罩 */}
|
||||
{sendingImage && (
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.overlayContent}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
<Text style={styles.overlayText}>发送图片中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* @成员选择浮层 - 绝对定位在输入框上方,覆盖消息列表 */}
|
||||
{activePanel === 'mention' && (
|
||||
<View
|
||||
style={[
|
||||
styles.mentionPanelFloat,
|
||||
{
|
||||
bottom: keyboardHeight > 0
|
||||
? keyboardHeight + inputWrapperHeight
|
||||
: inputWrapperHeight + (insets.bottom > 0 ? insets.bottom : 0),
|
||||
height: PANEL_HEIGHTS.mention,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MentionPanel
|
||||
members={groupMembers}
|
||||
currentUserId={currentUserId}
|
||||
mentionQuery={mentionQuery}
|
||||
currentUserRole={currentUserRole}
|
||||
onSelectMention={handleSelectMention}
|
||||
onMentionAll={handleMentionAll}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 长按菜单 */}
|
||||
<LongPressMenu
|
||||
visible={longPressMenuVisible}
|
||||
message={selectedMessage}
|
||||
currentUserId={currentUserId}
|
||||
position={menuPosition}
|
||||
onClose={hideLongPressMenu}
|
||||
onReply={handleReplyMessage}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDeleteMessage}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={chatImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={handleCloseImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatScreen;
|
||||
480
src/screens/message/CreateGroupScreen.tsx
Normal file
480
src/screens/message/CreateGroupScreen.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* CreateGroupScreen 创建群聊界面
|
||||
* 允许用户创建新的群聊,设置群名称、描述,并选择初始成员
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
TextInput,
|
||||
FlatList,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { Avatar, Text, Button, Loading } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const CreateGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
// 表单状态
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupDescription, setGroupDescription] = useState('');
|
||||
const [groupAvatar, setGroupAvatar] = useState<string | null>(null);
|
||||
const [selectedMembers, setSelectedMembers] = useState<User[]>([]);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
|
||||
// 邀请成员模态框状态
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||
|
||||
// 移除已选成员
|
||||
const removeSelectedMember = (userId: string) => {
|
||||
const newSelectedIds = new Set(selectedMemberIds);
|
||||
newSelectedIds.delete(userId);
|
||||
setSelectedMemberIds(newSelectedIds);
|
||||
setSelectedMembers(prev => prev.filter(m => m.id !== userId));
|
||||
};
|
||||
|
||||
// 选择群头像
|
||||
const handleSelectAvatar = async () => {
|
||||
try {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('提示', '需要访问相册权限才能选择头像');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
const selectedAsset = result.assets[0];
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadGroupAvatar({
|
||||
uri: selectedAsset.uri,
|
||||
type: selectedAsset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult && uploadResult.url) {
|
||||
setGroupAvatar(uploadResult.url);
|
||||
} else {
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择头像失败:', error);
|
||||
Alert.alert('错误', '选择头像时发生错误');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建群组
|
||||
const handleCreateGroup = async () => {
|
||||
// 验证群名称
|
||||
if (!groupName.trim()) {
|
||||
Alert.alert('提示', '请输入群名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName.length > 50) {
|
||||
Alert.alert('提示', '群名称最多50个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupDescription.length > 500) {
|
||||
Alert.alert('提示', '群描述最多500个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const memberIds = selectedMembers.map(m => m.id);
|
||||
const response = await groupService.createGroup({
|
||||
name: groupName.trim(),
|
||||
description: groupDescription.trim() || undefined,
|
||||
member_ids: memberIds.length > 0 ? memberIds : undefined,
|
||||
});
|
||||
|
||||
// 如果设置了头像,更新群头像
|
||||
if (groupAvatar && response.id) {
|
||||
try {
|
||||
await groupService.setGroupAvatar(response.id, groupAvatar);
|
||||
} catch (avatarError) {
|
||||
console.error('设置群头像失败:', avatarError);
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert('成功', '群组创建成功', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('创建群组失败:', error);
|
||||
Alert.alert('创建失败', error.message || '创建群组时发生错误,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染已选成员
|
||||
const renderSelectedMember = ({ item }: { item: User }) => (
|
||||
<View style={styles.selectedMemberItem}>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeMemberButton}
|
||||
onPress={() => removeSelectedMember(item.id)}
|
||||
>
|
||||
<View style={styles.removeIconContainer}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text variant="caption" numberOfLines={1} style={styles.selectedMemberName}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleConfirmMembers = (users: User[]) => {
|
||||
setSelectedMembers(users);
|
||||
setSelectedMemberIds(new Set(users.map(user => user.id)));
|
||||
setInviteModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 群头像和名称区域 */}
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
|
||||
{uploadingAvatar ? (
|
||||
<View style={[styles.avatarPlaceholder, { width: 80, height: 80 }]}>
|
||||
<Loading />
|
||||
</View>
|
||||
) : groupAvatar ? (
|
||||
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Avatar
|
||||
source={undefined}
|
||||
size={80}
|
||||
name={groupName || '群'}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.avatarBadge}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.nameInputContainer}>
|
||||
<Text variant="label" style={styles.inputLabel}>
|
||||
群名称 <Text color={colors.error.main}>*</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.nameInput}
|
||||
value={groupName}
|
||||
onChangeText={setGroupName}
|
||||
placeholder="请输入群名称"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{groupName.length}/50
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 群描述输入 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
群描述
|
||||
</Text>
|
||||
<View style={styles.textAreaContainer}>
|
||||
<TextInput
|
||||
style={styles.textArea}
|
||||
value={groupDescription}
|
||||
onChangeText={setGroupDescription}
|
||||
placeholder="介绍一下这个群聊吧..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={500}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
||||
{groupDescription.length}/500
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 已选成员 */}
|
||||
{selectedMembers.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
已选成员
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
{selectedMembers.length}人
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={selectedMembers}
|
||||
renderItem={renderSelectedMember}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.selectedMembersList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 邀请成员按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.inviteButton}
|
||||
onPress={() => setInviteModalVisible(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<MaterialCommunityIcons name="account-plus" size={24} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.inviteTextContainer}>
|
||||
<Text variant="body" style={styles.inviteTitle}>邀请成员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
仅可从互关好友中选择成员加入群聊
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
{/* 创建按钮 */}
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="创建群聊"
|
||||
onPress={handleCreateGroup}
|
||||
loading={submitting}
|
||||
disabled={!groupName.trim() || submitting}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MutualFollowSelectorModal
|
||||
visible={inviteModalVisible}
|
||||
title="邀请成员"
|
||||
confirmText="完成"
|
||||
initialSelectedIds={Array.from(selectedMemberIds)}
|
||||
onClose={() => setInviteModalVisible(false)}
|
||||
onConfirm={handleConfirmMembers}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
// 头部区域样式
|
||||
headerSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: spacing.lg,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary.main,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
nameInputContainer: {
|
||||
flex: 1,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInput: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.lg,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
charCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
// 区域样式
|
||||
section: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// 文本域样式
|
||||
textAreaContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
padding: spacing.md,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
textAreaCharCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
// 已选成员样式
|
||||
selectedMembersList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
selectedMemberItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
width: 64,
|
||||
},
|
||||
removeMemberButton: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: 4,
|
||||
},
|
||||
removeIconContainer: {
|
||||
backgroundColor: colors.text.secondary,
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
selectedMemberName: {
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
// 邀请按钮样式
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.sm,
|
||||
},
|
||||
inviteIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
inviteTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
inviteTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
// 底部按钮样式
|
||||
footer: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
});
|
||||
|
||||
export default CreateGroupScreen;
|
||||
1563
src/screens/message/GroupInfoScreen.tsx
Normal file
1563
src/screens/message/GroupInfoScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
231
src/screens/message/GroupInviteDetailScreen.tsx
Normal file
231
src/screens/message/GroupInviteDetailScreen.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { GroupMemberResponse } from '../../types/dto';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupInviteDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupInviteDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const { extra_data } = message;
|
||||
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [loadingGroup, setLoadingGroup] = useState(false);
|
||||
|
||||
const canAction = extra_data?.request_status === 'pending';
|
||||
|
||||
useEffect(() => {
|
||||
const loadGroup = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
} finally {
|
||||
setLoadingGroup(false);
|
||||
}
|
||||
};
|
||||
loadGroup();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMembers = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const res = await groupManager.getMembers(extra_data.group_id, 1, 100);
|
||||
const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin');
|
||||
setMembers(admins);
|
||||
} catch {
|
||||
setMembers([]);
|
||||
} finally {
|
||||
setLoadingMembers(false);
|
||||
}
|
||||
};
|
||||
loadMembers();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少邀请标识,无法处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await groupService.respondInvite({ flag, approve });
|
||||
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} catch {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]);
|
||||
const formatGroupNo = (id: string) => {
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<GroupInfoSummaryCard
|
||||
groupName={extra_data?.group_name}
|
||||
groupAvatar={extra_data?.group_avatar}
|
||||
groupNo={formatGroupNo(groupNo)}
|
||||
groupDescription={extra_data?.group_description}
|
||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||
/>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="account-multiple" size={18} color={colors.info.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群主与管理员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.memberCount}>
|
||||
{members.length}人
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loadingMembers ? (
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.memberPreview}>
|
||||
{members.slice(0, 12).map((member, index) => (
|
||||
<View
|
||||
key={member.id}
|
||||
style={[
|
||||
styles.memberAvatar,
|
||||
index === 0 && styles.memberAvatarFirst,
|
||||
{ zIndex: index + 1 },
|
||||
]}
|
||||
>
|
||||
<Avatar
|
||||
source={member.user?.avatar || ''}
|
||||
size={44}
|
||||
name={member.user?.nickname || member.nickname}
|
||||
/>
|
||||
{member.role === 'owner' && (
|
||||
<View style={styles.ownerBadge}>
|
||||
<Text variant="caption" color={colors.background.paper} style={styles.ownerBadgeText}>主</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
暂无可展示的管理员信息
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<DecisionFooter
|
||||
canAction={canAction}
|
||||
submitting={submitting}
|
||||
onReject={() => handleDecision(false)}
|
||||
onApprove={() => handleDecision(true)}
|
||||
processedText="该邀请已处理"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
...shadows.sm,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
cardIconContainer: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: colors.info.light + '30',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
memberCount: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
loadingWrap: {
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
memberPreview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
memberAvatar: {
|
||||
marginLeft: -8,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
memberAvatarFirst: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
ownerBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
backgroundColor: colors.warning.main,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
ownerBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupInviteDetailScreen;
|
||||
722
src/screens/message/GroupMembersScreen.tsx
Normal file
722
src/screens/message/GroupMembersScreen.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* GroupMembersScreen 群成员管理界面
|
||||
* 显示群成员列表,支持管理员管理成员
|
||||
* 支持响应式网格布局
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ListRenderItem,
|
||||
Modal,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
GroupMemberResponse,
|
||||
GroupRole,
|
||||
} from '../../types/dto';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 网格布局配置
|
||||
const GRID_CONFIG = {
|
||||
mobile: { columns: 1, itemWidth: '100%' },
|
||||
tablet: { columns: 2, itemWidth: '48%' },
|
||||
desktop: { columns: 3, itemWidth: '31%' },
|
||||
};
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type GroupMembersRouteProp = RouteProp<RootStackParamList, 'GroupMembers'>;
|
||||
|
||||
// 成员分组
|
||||
interface MemberGroup {
|
||||
title: string;
|
||||
data: GroupMemberResponse[];
|
||||
}
|
||||
|
||||
const GroupMembersScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<GroupMembersRouteProp>();
|
||||
const { groupId } = route.params;
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算网格列数
|
||||
const gridConfig = useMemo(() => {
|
||||
if (width >= 1024) return GRID_CONFIG.desktop;
|
||||
if (width >= 768) return GRID_CONFIG.tablet;
|
||||
return GRID_CONFIG.mobile;
|
||||
}, [width]);
|
||||
|
||||
// 成员列表状态
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// 当前用户的成员信息
|
||||
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
|
||||
|
||||
// 操作模态框状态
|
||||
const [actionModalVisible, setActionModalVisible] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<GroupMemberResponse | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// 设置群昵称模态框
|
||||
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
|
||||
const [newNickname, setNewNickname] = useState('');
|
||||
|
||||
// 计算当前用户的角色
|
||||
const isOwner = currentMember?.role === 'owner';
|
||||
const isAdmin = currentMember?.role === 'admin' || isOwner;
|
||||
|
||||
// 加载成员列表
|
||||
const loadMembers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
||||
if (!hasMore && !refresh) return;
|
||||
|
||||
try {
|
||||
const response = await groupManager.getMembers(groupId, pageNum, 50);
|
||||
|
||||
if (refresh) {
|
||||
setMembers(response.list);
|
||||
setPage(1);
|
||||
} else {
|
||||
setMembers(prev => [...prev, ...response.list]);
|
||||
}
|
||||
|
||||
setHasMore(response.list.length === 50);
|
||||
|
||||
// 查找当前用户的成员信息
|
||||
const myMember = response.list.find(m => m.user_id === currentUser?.id);
|
||||
if (myMember) {
|
||||
setCurrentMember(myMember);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载成员列表失败:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, [groupId, currentUser, hasMore]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadMembers(1, true);
|
||||
}, [groupId]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setHasMore(true);
|
||||
loadMembers(1, true);
|
||||
}, [loadMembers]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
loadMembers(nextPage);
|
||||
}
|
||||
}, [loading, hasMore, page, loadMembers]);
|
||||
|
||||
// 按角色分组
|
||||
const groupMembers = useCallback((): MemberGroup[] => {
|
||||
const owners = members.filter(m => m.role === 'owner');
|
||||
const admins = members.filter(m => m.role === 'admin');
|
||||
const normalMembers = members.filter(m => m.role === 'member');
|
||||
|
||||
const groups: MemberGroup[] = [];
|
||||
|
||||
if (owners.length > 0) {
|
||||
groups.push({ title: '群主', data: owners });
|
||||
}
|
||||
if (admins.length > 0) {
|
||||
groups.push({ title: '管理员', data: admins });
|
||||
}
|
||||
if (normalMembers.length > 0) {
|
||||
groups.push({ title: '成员', data: normalMembers });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [members]);
|
||||
|
||||
// 打开操作菜单
|
||||
const openActionModal = (member: GroupMemberResponse) => {
|
||||
// 不能操作群主
|
||||
if (member.role === 'owner') return;
|
||||
|
||||
// 普通成员不能操作其他人
|
||||
if (!isAdmin) return;
|
||||
|
||||
// 管理员不能操作其他管理员(只有群主可以)
|
||||
if (!isOwner && member.role === 'admin') return;
|
||||
|
||||
setSelectedMember(member);
|
||||
setActionModalVisible(true);
|
||||
};
|
||||
|
||||
// 设置/取消管理员
|
||||
const handleToggleAdmin = async () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
const newRole: GroupRole = selectedMember.role === 'admin' ? 'member' : 'admin';
|
||||
const actionText = newRole === 'admin' ? '设为管理员' : '取消管理员';
|
||||
|
||||
Alert.alert(
|
||||
actionText,
|
||||
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.setMemberRole(groupId, selectedMember.user_id, {
|
||||
role: newRole as 'admin' | 'member',
|
||||
});
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, role: newRole };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', `已${actionText}`);
|
||||
} catch (error: any) {
|
||||
console.error('设置角色失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 禁言/解禁成员
|
||||
const handleToggleMute = async () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
const newMuted = !selectedMember.muted;
|
||||
const actionText = newMuted ? '禁言' : '解禁';
|
||||
|
||||
Alert.alert(
|
||||
`${actionText}成员`,
|
||||
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
// duration: 0 表示解除禁言,-1 表示永久禁言
|
||||
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, muted: newMuted };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', `已${actionText}`);
|
||||
} catch (error: any) {
|
||||
console.error('禁言操作失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 移除成员
|
||||
const handleRemoveMember = () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
Alert.alert(
|
||||
'移除成员',
|
||||
`确定要将 "${selectedMember.user?.nickname || selectedMember.nickname}" 移出群聊吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '移除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.removeMember(groupId, selectedMember.user_id);
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', '已移除成员');
|
||||
} catch (error: any) {
|
||||
console.error('移除成员失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 打开设置群昵称模态框
|
||||
const openNicknameModal = (member: GroupMemberResponse) => {
|
||||
setSelectedMember(member);
|
||||
setNewNickname(member.nickname || member.user?.nickname || '');
|
||||
setNicknameModalVisible(true);
|
||||
};
|
||||
|
||||
// 设置群昵称(仅限自己)
|
||||
const handleSetNickname = async () => {
|
||||
if (!selectedMember || selectedMember.user_id !== currentUser?.id) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.setNickname(groupId, {
|
||||
nickname: newNickname.trim() || selectedMember.user?.nickname || '',
|
||||
});
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, nickname: newNickname.trim() };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setNicknameModalVisible(false);
|
||||
Alert.alert('成功', '群昵称已更新');
|
||||
} catch (error: any) {
|
||||
console.error('设置群昵称失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签颜色
|
||||
const getRoleBadgeColor = (role: GroupRole): string => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return colors.warning.main;
|
||||
case 'admin':
|
||||
return colors.primary.main;
|
||||
default:
|
||||
return colors.text.hint;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签文本
|
||||
const getRoleBadgeText = (role: GroupRole): string => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return '群主';
|
||||
case 'admin':
|
||||
return '管理员';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染成员项
|
||||
const renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
|
||||
const isSelf = item.user_id === currentUser?.id;
|
||||
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.memberItem}
|
||||
onPress={() => canManage ? openActionModal(item) : null}
|
||||
onLongPress={() => isSelf ? openNicknameModal(item) : null}
|
||||
activeOpacity={canManage ? 0.7 : 1}
|
||||
>
|
||||
<Avatar
|
||||
source={item.user?.avatar}
|
||||
size={48}
|
||||
name={item.user?.nickname || item.nickname}
|
||||
/>
|
||||
<View style={styles.memberInfo}>
|
||||
<View style={styles.memberNameRow}>
|
||||
<Text variant="body" style={styles.memberName}>
|
||||
{item.nickname || item.user?.nickname || '未知用户'}
|
||||
</Text>
|
||||
{item.role !== 'member' && (
|
||||
<View style={[styles.roleBadge, { backgroundColor: getRoleBadgeColor(item.role) }]}>
|
||||
<Text variant="label" color={colors.background.paper}>
|
||||
{getRoleBadgeText(item.role)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{item.user?.username || 'unknown'}
|
||||
</Text>
|
||||
{item.muted && (
|
||||
<View style={styles.mutedBadge}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
|
||||
<Text variant="label" color={colors.error.main}> 禁言中</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{canManage && (
|
||||
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分组头部
|
||||
const renderSectionHeader = (title: string, count: number) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="label" color={colors.text.secondary}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{count}人
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无成员"
|
||||
description="群组还没有成员"
|
||||
icon="account-group-outline"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染操作菜单
|
||||
const renderActionModal = () => {
|
||||
if (!selectedMember) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={actionModalVisible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setActionModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Avatar
|
||||
source={selectedMember.user?.avatar}
|
||||
size={60}
|
||||
name={selectedMember.user?.nickname || selectedMember.nickname}
|
||||
/>
|
||||
<Text variant="h3" style={styles.modalTitle}>
|
||||
{selectedMember.user?.nickname || selectedMember.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{selectedMember.user?.username}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 设置/取消管理员(仅群主) */}
|
||||
{isOwner && selectedMember.role !== 'owner' && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleToggleAdmin}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
|
||||
size={22}
|
||||
color={colors.text.primary}
|
||||
/>
|
||||
<Text variant="body" style={styles.actionText}>
|
||||
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 禁言/解禁 */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleToggleMute}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
|
||||
size={22}
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
style={styles.actionText}
|
||||
>
|
||||
{selectedMember.muted ? '解除禁言' : '禁言'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 移除成员 */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleRemoveMember}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.actionText}>
|
||||
移除成员
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
title="取消"
|
||||
variant="outline"
|
||||
onPress={() => setActionModalVisible(false)}
|
||||
fullWidth
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置群昵称模态框
|
||||
const renderNicknameModal = () => {
|
||||
if (!selectedMember) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={nicknameModalVisible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setNicknameModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text variant="h3" style={styles.modalTitle}>设置群昵称</Text>
|
||||
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.inputLabel}>
|
||||
在本群的昵称(选填)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={newNickname}
|
||||
onChangeText={setNewNickname}
|
||||
placeholder={selectedMember.user?.nickname || '请输入群昵称'}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={20}
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Button
|
||||
title="取消"
|
||||
variant="outline"
|
||||
onPress={() => setNicknameModalVisible(false)}
|
||||
style={styles.modalButton}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
<Button
|
||||
title="保存"
|
||||
onPress={handleSetNickname}
|
||||
loading={actionLoading}
|
||||
style={styles.modalButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分组列表
|
||||
const renderGroupedList = () => {
|
||||
const groups = groupMembers();
|
||||
|
||||
if (groups.length === 0) {
|
||||
return renderEmpty();
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={groups}
|
||||
keyExtractor={(item) => item.title}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: group }) => (
|
||||
<View style={styles.section}>
|
||||
{renderSectionHeader(group.title, group.data.length)}
|
||||
{group.data.map((member, index) => (
|
||||
<View key={member.id}>
|
||||
{renderMember({ item: member, index } as any)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{loading ? <Loading /> : renderGroupedList()}
|
||||
{renderActionModal()}
|
||||
{renderNicknameModal()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
memberInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
memberNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
memberName: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
roleBadge: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
mutedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: borderRadius.lg,
|
||||
borderTopRightRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
actionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupMembersScreen;
|
||||
197
src/screens/message/GroupRequestDetailScreen.tsx
Normal file
197
src/screens/message/GroupRequestDetailScreen.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupRequestDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupRequestDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const { extra_data } = message;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [loadingGroup, setLoadingGroup] = useState(false);
|
||||
|
||||
const requestType = extra_data?.request_type;
|
||||
const requestStatus = extra_data?.request_status;
|
||||
const reviewerName = extra_data?.actor_name || extra_data?.operator_name || '管理员';
|
||||
const canAction =
|
||||
requestStatus === 'pending' &&
|
||||
(message.system_type === 'group_join_apply' || message.system_type === 'group_invite');
|
||||
const processedText =
|
||||
requestStatus === 'accepted'
|
||||
? `${reviewerName}已同意`
|
||||
: requestStatus === 'rejected'
|
||||
? `${reviewerName}已拒绝`
|
||||
: '该请求已处理';
|
||||
|
||||
const applicantName = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_name || '被邀请用户';
|
||||
}
|
||||
return extra_data?.actor_name || '申请用户';
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
const applicantAvatar = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_avatar || '';
|
||||
}
|
||||
return extra_data?.avatar_url || '';
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
const applicantId = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_id;
|
||||
}
|
||||
return extra_data?.actor_id_str;
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGroupInfo = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
} finally {
|
||||
setLoadingGroup(false);
|
||||
}
|
||||
};
|
||||
loadGroupInfo();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少请求标识,无法处理');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (message.system_type === 'group_invite') {
|
||||
await groupService.respondInvite({
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
} else {
|
||||
await groupService.reviewJoinRequest({
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
}
|
||||
Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUser = async () => {
|
||||
if (!applicantId) return;
|
||||
const user = await userManager.getUserById(applicantId);
|
||||
if (user?.id) {
|
||||
navigation.navigate('UserProfile', { userId: String(user.id) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<GroupInfoSummaryCard
|
||||
groupName={extra_data?.group_name}
|
||||
groupAvatar={extra_data?.group_avatar}
|
||||
groupNo={extra_data?.group_id}
|
||||
groupDescription={extra_data?.group_description}
|
||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||
/>
|
||||
<View style={styles.card}>
|
||||
<Text variant="label" style={styles.sectionTitle}>用户信息</Text>
|
||||
<TouchableOpacity style={styles.row} activeOpacity={0.7} onPress={handleOpenUser} disabled={!applicantId}>
|
||||
<Avatar source={applicantAvatar} size={44} name={applicantName} />
|
||||
<View style={styles.meta}>
|
||||
<Text variant="body" style={styles.name}>{applicantName}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{requestType === 'invite' ? '被邀请加入' : '申请加入'}
|
||||
</Text>
|
||||
</View>
|
||||
{applicantId ? (
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
{requestType === 'invite' && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subDesc}>
|
||||
邀请人:{extra_data?.actor_name || '群成员'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<DecisionFooter
|
||||
canAction={canAction}
|
||||
submitting={submitting}
|
||||
onReject={() => handleDecision(false)}
|
||||
onApprove={() => handleDecision(true)}
|
||||
processedText={processedText}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
...shadows.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
meta: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
marginBottom: 2,
|
||||
},
|
||||
subDesc: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupRequestDetailScreen;
|
||||
307
src/screens/message/JoinGroupScreen.tsx
Normal file
307
src/screens/message/JoinGroupScreen.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import Avatar from '../../components/common/Avatar';
|
||||
import Text from '../../components/common/Text';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { GroupResponse, JoinType } from '../../types/dto';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const JoinGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [group, setGroup] = useState<GroupResponse | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const getJoinTypeText = (joinType: JoinType) => {
|
||||
if (joinType === 0) return '允许加入';
|
||||
if (joinType === 1) return '需要审批';
|
||||
return '禁止加入';
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert('提示', '请输入群ID进行搜索');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const result = await groupManager.getGroup(trimmed, true);
|
||||
setGroup(result);
|
||||
} catch (error: any) {
|
||||
setGroup(null);
|
||||
const message = error?.response?.data?.message || error?.message || '';
|
||||
if (String(message).includes('不存在') || error?.response?.status === 404) {
|
||||
Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确');
|
||||
} else {
|
||||
Alert.alert('搜索失败', '请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!group?.id) return;
|
||||
setJoining(true);
|
||||
try {
|
||||
await groupService.joinGroup(group.id);
|
||||
Alert.alert('成功', '操作已提交', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'操作失败,请稍后重试';
|
||||
Alert.alert('操作失败', String(message));
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyGroupId = () => {
|
||||
if (!group?.id) return;
|
||||
Clipboard.setString(String(group.id));
|
||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||
};
|
||||
|
||||
const formatGroupNo = (id: string | number) => {
|
||||
const raw = String(id);
|
||||
if (raw.length <= 12) return raw;
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroIconWrap}>
|
||||
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="h3" style={styles.heroTitle}>搜索群聊</Text>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.tip}>
|
||||
输入群 ID 搜索后,你可以先查看群资料,再决定是否申请加入。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>搜索群聊(群ID)</Text>
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="例如:7391234567890"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
style={styles.input}
|
||||
editable={!searching && !joining}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
|
||||
onPress={handleSearch}
|
||||
disabled={!keyword.trim() || searching || joining}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{group && (
|
||||
<View style={styles.groupCard}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar source={group.avatar} size={52} name={group.name} />
|
||||
<View style={styles.groupMeta}>
|
||||
<Text variant="body" style={styles.groupName} numberOfLines={1}>{group.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!!group.description && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{group.description}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.groupInfoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
成员 {group.member_count}/{group.max_members}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{getJoinTypeText(group.join_type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.groupNoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
群号:{formatGroupNo(group.id)}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.copyBtn} onPress={handleCopyGroupId}>
|
||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>复制</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitBtn, joining && styles.submitBtnDisabled]}
|
||||
onPress={handleJoin}
|
||||
disabled={joining}
|
||||
>
|
||||
{joining ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
||||
<Text variant="body" color="#fff" style={styles.submitText}>申请入群</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{searched && !group && !searching && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
||||
暂无搜索结果,请检查群ID后重试
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
heroCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
heroIconWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.primary.light + '26',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
heroTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
tip: {
|
||||
lineHeight: 20,
|
||||
},
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
searchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
searchBtn: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
groupCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
groupMeta: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
groupName: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
groupDesc: {
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
groupInfoRow: {
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
groupNoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copyBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.primary.light + '22',
|
||||
},
|
||||
copyBtnText: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
submitBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
minHeight: 46,
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
submitText: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default JoinGroupScreen;
|
||||
1324
src/screens/message/MessageListScreen.tsx
Normal file
1324
src/screens/message/MessageListScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
540
src/screens/message/NotificationsScreen.tsx
Normal file
540
src/screens/message/NotificationsScreen.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 通知页 NotificationsScreen
|
||||
* 胡萝卜BBS - 系统消息列表
|
||||
* 使用新的系统消息API
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { commentService } from '../../services/commentService';
|
||||
import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||
|
||||
const MESSAGE_TYPES = [
|
||||
{ key: 'all', title: '全部' },
|
||||
{ key: 'like_post', title: '点赞' },
|
||||
{ key: 'comment', title: '评论' },
|
||||
{ key: 'follow', title: '关注' },
|
||||
{ key: 'group', title: '群通知' },
|
||||
{ key: 'system', title: '系统' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
];
|
||||
|
||||
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
|
||||
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
|
||||
|
||||
export const NotificationsScreen: React.FC = () => {
|
||||
const isFocused = useIsFocused();
|
||||
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
||||
const [activeType, setActiveType] = useState('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
||||
const displayMessages = useMemo(() => {
|
||||
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
||||
messages.forEach((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return;
|
||||
const flag = msg.extra_data?.flag;
|
||||
const status = msg.extra_data?.request_status;
|
||||
if (!flag) return;
|
||||
if (status === 'accepted' || status === 'rejected') {
|
||||
reviewedByFlag.set(flag, msg);
|
||||
}
|
||||
});
|
||||
|
||||
if (reviewedByFlag.size === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return msg;
|
||||
const flag = msg.extra_data?.flag;
|
||||
if (!flag) return msg;
|
||||
const reviewedMsg = reviewedByFlag.get(flag);
|
||||
if (!reviewedMsg) return msg;
|
||||
const reviewedStatus = reviewedMsg.extra_data?.request_status;
|
||||
if (reviewedStatus !== 'accepted' && reviewedStatus !== 'rejected') return msg;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
extra_data: {
|
||||
...msg.extra_data,
|
||||
request_status: reviewedStatus,
|
||||
actor_name: reviewedMsg.extra_data?.actor_name || reviewedMsg.extra_data?.operator_name || msg.extra_data?.actor_name,
|
||||
avatar_url: reviewedMsg.extra_data?.avatar_url || reviewedMsg.extra_data?.operator_avatar || msg.extra_data?.avatar_url,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// 获取系统消息数据
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await messageService.getSystemMessages(50, 1);
|
||||
// 添加防御性检查,确保 messages 数组存在
|
||||
setMessages(response.messages || []);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('获取系统消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取未读数
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await messageService.getSystemUnreadCount();
|
||||
setUnreadCount(response.unread_count);
|
||||
} catch (error) {
|
||||
console.error('获取未读数失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 一键已读
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
await messageService.markAllSystemMessagesRead();
|
||||
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
setSystemUnreadCount(0);
|
||||
// 同步更新全局 TabBar 红点
|
||||
fetchMessageUnreadCount();
|
||||
// 刷新消息列表
|
||||
fetchMessages();
|
||||
} catch (error) {
|
||||
console.error('一键已读失败:', error);
|
||||
}
|
||||
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
|
||||
|
||||
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
fetchMessages();
|
||||
fetchUnreadCount();
|
||||
// 进入界面自动标记所有消息为已读
|
||||
handleMarkAllRead();
|
||||
}
|
||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
||||
|
||||
// 屏幕失去焦点时返回消息列表
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
// 使用 setTimeout 确保在导航状态稳定后再执行
|
||||
const timer = setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFocused, navigation]);
|
||||
|
||||
// 筛选消息
|
||||
const filteredMessages = activeType === 'all'
|
||||
? displayMessages
|
||||
: displayMessages.filter((m) => {
|
||||
if (activeType === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (activeType === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === activeType;
|
||||
});
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([fetchMessages(), fetchUnreadCount()]);
|
||||
setRefreshing(false);
|
||||
}, [fetchMessages, fetchUnreadCount]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || messages.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
// 使用时间戳或seq作为游标分页(后端使用page分页)
|
||||
const nextPage = Math.floor((messages.length / 20)) + 1;
|
||||
const response = await messageService.getSystemMessages(20, nextPage);
|
||||
// 添加防御性检查
|
||||
const newMessages = response.messages || [];
|
||||
setMessages(prev => [...prev, ...newMessages]);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('加载更多失败:', error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, messages]);
|
||||
|
||||
// 标记单条消息已读并处理导航
|
||||
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
|
||||
if (!actionUrl) return null;
|
||||
|
||||
const postPathMatch = actionUrl.match(/\/posts\/([^/?#]+)/);
|
||||
if (postPathMatch?.[1]) {
|
||||
return decodeURIComponent(postPathMatch[1]);
|
||||
}
|
||||
|
||||
const query = actionUrl.split('?')[1];
|
||||
if (!query) return null;
|
||||
const params = new URLSearchParams(query);
|
||||
return params.get('post') || params.get('post_id');
|
||||
};
|
||||
|
||||
const resolvePostId = async (message: SystemMessageResponse): Promise<string | null> => {
|
||||
const { extra_data, system_type } = message;
|
||||
|
||||
// 后端已统一将 target_id 设为帖子ID(like_post/comment/mention/favorite_post/like_reply/like_comment)
|
||||
if (
|
||||
extra_data?.target_id &&
|
||||
['like_post', 'like_comment', 'like_reply', 'comment', 'mention', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
return extra_data.target_id;
|
||||
}
|
||||
|
||||
// reply 通知:target_id 是 replyID,从 action_url 解析帖子ID
|
||||
const postIdFromUrl = extractPostIdFromActionUrl(extra_data?.action_url);
|
||||
if (postIdFromUrl) {
|
||||
return postIdFromUrl;
|
||||
}
|
||||
|
||||
// 兜底:通过评论详情反查(仅旧数据兜底)
|
||||
if (extra_data?.target_id && system_type === 'reply') {
|
||||
const comment = await commentService.getComment(extra_data.target_id);
|
||||
if (comment?.post_id) {
|
||||
return comment.post_id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleMessagePress = async (message: SystemMessageResponse) => {
|
||||
try {
|
||||
const messageId = String(message.id);
|
||||
const wasUnread = message.is_read !== true;
|
||||
await messageService.markSystemMessageRead(messageId);
|
||||
setMessages(prev =>
|
||||
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
|
||||
);
|
||||
if (wasUnread) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
decrementSystemUnreadCount(1);
|
||||
}
|
||||
// 更新本地未读数以及全局 TabBar 红点
|
||||
fetchUnreadCount();
|
||||
fetchMessageUnreadCount();
|
||||
|
||||
// 根据消息类型处理导航
|
||||
const { system_type, extra_data } = message;
|
||||
|
||||
if (
|
||||
['like_post', 'comment', 'mention', 'reply', 'like_comment', 'like_reply', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
const postId = await resolvePostId(message);
|
||||
if (postId) {
|
||||
navigation.navigate('PostDetail', { postId });
|
||||
}
|
||||
} else if (system_type === 'follow') {
|
||||
// 关注 - 跳转到用户主页
|
||||
if (extra_data?.actor_id_str) {
|
||||
navigation.navigate('UserProfile', { userId: extra_data.actor_id_str });
|
||||
}
|
||||
} else if (system_type === 'group_join_apply') {
|
||||
navigation.navigate('GroupRequestDetail', { message });
|
||||
} else if (system_type === 'group_invite') {
|
||||
navigation.navigate('GroupInviteDetail', { message });
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理头像点击 - 跳转到用户主页
|
||||
const handleAvatarPress = (message: SystemMessageResponse) => {
|
||||
const { extra_data } = message;
|
||||
// 优先使用 actor_id_str (UUID格式),兼容 actor_id (数字格式)
|
||||
const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null);
|
||||
|
||||
if (actorId) {
|
||||
navigation.navigate('UserProfile', { userId: actorId });
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息项
|
||||
const renderMessage = ({ item }: { item: SystemMessageResponse }) => (
|
||||
<SystemMessageItem
|
||||
message={item}
|
||||
onPress={() => handleMessagePress(item)}
|
||||
onAvatarPress={() => handleAvatarPress(item)}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = () => {
|
||||
if (!loadingMore) return null;
|
||||
return (
|
||||
<View style={styles.loadingMore}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.loadingMoreText}>
|
||||
加载中...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => (
|
||||
<EmptyState
|
||||
title="暂无通知"
|
||||
description="关注一些用户来获取通知吧"
|
||||
icon="bell-outline"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
isWideScreen && styles.filterTagWide,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<>
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
filterContainerWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginRight: spacing.sm,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterTagWide: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
filterTagActive: {
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
},
|
||||
filterCount: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
listContentWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl * 2,
|
||||
},
|
||||
loadingMore: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
loadingMoreText: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
490
src/screens/message/PrivateChatInfoScreen.tsx
Normal file
490
src/screens/message/PrivateChatInfoScreen.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* PrivateChatInfoScreen 私聊聊天管理页面
|
||||
* 胡萝卜BBS - 私聊设置界面
|
||||
* 参考QQ和微信的实现,提供聊天管理功能
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { authService } from '../../services/authService';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { ApiError } from '../../services/api';
|
||||
import {
|
||||
clearConversationMessages,
|
||||
getConversationCache,
|
||||
} from '../../services/database';
|
||||
import { messageManager } from '../../stores/messageManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
|
||||
|
||||
const PrivateChatInfoScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<PrivateChatInfoRouteProp>();
|
||||
const { conversationId, userId, userName, userAvatar } = route.params;
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 用户信息状态
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
|
||||
// 聊天设置状态
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
// 加载用户信息
|
||||
const loadUserInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userData = await userManager.getUserById(userId);
|
||||
setUser(userData || null);
|
||||
const blockStatus = await authService.getBlockStatus(userId);
|
||||
setIsBlocked(blockStatus);
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// 加载聊天设置
|
||||
const loadChatSettings = useCallback(async () => {
|
||||
try {
|
||||
const currentConv = messageManager.getConversation(conversationId);
|
||||
if (currentConv) {
|
||||
setIsPinned(Boolean(currentConv.is_pinned));
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await getConversationCache(conversationId);
|
||||
if (cached) {
|
||||
setIsPinned(Boolean((cached as any).is_pinned));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await messageService.getConversationById(conversationId);
|
||||
setIsPinned(Boolean(detail.is_pinned));
|
||||
} catch (error) {
|
||||
console.error('加载聊天设置失败:', error);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo();
|
||||
loadChatSettings();
|
||||
}, [loadUserInfo, loadChatSettings]);
|
||||
|
||||
// 切换免打扰(仅本地状态,实际需要API支持)
|
||||
const toggleMute = async () => {
|
||||
try {
|
||||
const newValue = !isMuted;
|
||||
setIsMuted(newValue);
|
||||
// TODO: 调用API更新免打扰状态
|
||||
// await conversationService.updateConversationMute(conversationId, newValue);
|
||||
} catch (error) {
|
||||
setIsMuted(!isMuted);
|
||||
Alert.alert('错误', '设置免打扰失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换置顶
|
||||
const togglePin = async () => {
|
||||
const nextValue = !isPinned;
|
||||
setIsPinned(nextValue);
|
||||
try {
|
||||
await messageService.setConversationPinned(conversationId, nextValue);
|
||||
messageManager.updateConversation(conversationId, { is_pinned: nextValue });
|
||||
} catch (error) {
|
||||
setIsPinned(!nextValue);
|
||||
Alert.alert('错误', '设置置顶失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 清空聊天记录
|
||||
const handleClearHistory = () => {
|
||||
Alert.alert(
|
||||
'清空聊天记录',
|
||||
'确定要清空与该用户的所有聊天记录吗?此操作不可恢复。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '清空',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await clearConversationMessages(conversationId);
|
||||
Alert.alert('成功', '聊天记录已清空');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '清空聊天记录失败');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 查找聊天记录
|
||||
const handleSearchMessages = () => {
|
||||
// TODO: 实现聊天记录搜索功能
|
||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
||||
};
|
||||
|
||||
// 查看用户资料
|
||||
const handleViewProfile = () => {
|
||||
// 使用根导航跳转到用户资料页面
|
||||
const rootNavigation = navigation.getParent();
|
||||
rootNavigation?.navigate('UserProfile' as any, { userId });
|
||||
};
|
||||
|
||||
// 举报/投诉
|
||||
const handleReport = () => {
|
||||
Alert.alert(
|
||||
'举报用户',
|
||||
'请选择举报原因',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '骚扰信息', onPress: () => submitReport('harassment') },
|
||||
{ text: '欺诈行为', onPress: () => submitReport('fraud') },
|
||||
{ text: '不当内容', onPress: () => submitReport('inappropriate') },
|
||||
{ text: '其他原因', onPress: () => submitReport('other') },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const submitReport = async (reason: string) => {
|
||||
try {
|
||||
// TODO: 实现举报API
|
||||
Alert.alert('举报已提交', '我们会尽快处理您的举报,感谢您的反馈');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '举报提交失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = () => {
|
||||
if (isBlocked) {
|
||||
Alert.alert(
|
||||
'取消拉黑',
|
||||
'确定取消拉黑该用户吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
const ok = await authService.unblockUser(userId);
|
||||
if (!ok) {
|
||||
Alert.alert('错误', '取消拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setIsBlocked(false);
|
||||
Alert.alert('成功', '已取消拉黑');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'拉黑用户',
|
||||
'拉黑后,对方将无法给你发送私聊消息,且你们会互相移除关注关系。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确认拉黑',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const ok = await authService.blockUser(userId);
|
||||
if (!ok) {
|
||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setIsBlocked(true);
|
||||
messageManager.removeConversation(conversationId);
|
||||
navigation.navigate('MessageList' as any);
|
||||
Alert.alert('已拉黑', '你已成功拉黑该用户');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 删除并退出聊天
|
||||
const handleDeleteAndExit = () => {
|
||||
Alert.alert(
|
||||
'删除聊天',
|
||||
'确定要删除与该用户的聊天吗?聊天记录将被清空。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await messageService.deleteConversationForSelf(conversationId);
|
||||
messageManager.removeConversation(conversationId);
|
||||
// 返回消息列表
|
||||
navigation.navigate('MessageList' as any);
|
||||
} catch (error) {
|
||||
const msg = error instanceof ApiError ? error.message : '删除聊天失败';
|
||||
Alert.alert('错误', msg);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置项(带开关)
|
||||
const renderSwitchItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: '#E0E0E0', true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : '#F5F5F5'}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染设置项(带箭头)
|
||||
const renderActionItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
onPress: () => void,
|
||||
danger: boolean = false
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" style={danger ? [styles.settingTitle, styles.dangerText] : styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Loading />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const displayUser = user || {
|
||||
id: userId,
|
||||
nickname: userName,
|
||||
avatar: userAvatar,
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 用户基本信息卡片 */}
|
||||
<View style={styles.headerCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.userHeader}
|
||||
onPress={handleViewProfile}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar
|
||||
source={displayUser.avatar}
|
||||
size={90}
|
||||
name={displayUser.nickname || ''}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h3" style={styles.userName} numberOfLines={1}>
|
||||
{displayUser.nickname || userName || '用户'}
|
||||
</Text>
|
||||
{(displayUser as any).username && (
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{(displayUser as any).username}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天设置
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderSwitchItem('bell-off-outline', '消息免打扰', isMuted, toggleMute)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderSwitchItem('pin-outline', '置顶聊天', isPinned, togglePin)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 聊天记录管理 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天记录
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('magnify', '查找聊天记录', handleSearchMessages)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem('delete-sweep-outline', '清空聊天记录', handleClearHistory, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 其他操作 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
其他
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('shield-alert-outline', '投诉', handleReport, true)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem(
|
||||
isBlocked ? 'account-check-outline' : 'account-cancel-outline',
|
||||
isBlocked ? '取消拉黑' : '拉黑用户',
|
||||
handleBlockUser,
|
||||
true
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除聊天按钮 */}
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="删除并退出聊天"
|
||||
onPress={handleDeleteAndExit}
|
||||
variant="danger"
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
...shadows.sm,
|
||||
},
|
||||
userHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
...shadows.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
settingTitle: {
|
||||
flex: 1,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
divider: {
|
||||
marginLeft: spacing.xl + 36,
|
||||
width: 'auto',
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
deleteButton: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default PrivateChatInfoScreen;
|
||||
129
src/screens/message/components/ChatScreen/ChatHeader.tsx
Normal file
129
src/screens/message/components/ChatScreen/ChatHeader.tsx
Normal 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;
|
||||
173
src/screens/message/components/ChatScreen/ChatInput.tsx
Normal file
173
src/screens/message/components/ChatScreen/ChatInput.tsx
Normal 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;
|
||||
556
src/screens/message/components/ChatScreen/EmojiPanel.tsx
Normal file
556
src/screens/message/components/ChatScreen/EmojiPanel.tsx
Normal 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;
|
||||
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal file
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal 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;
|
||||
102
src/screens/message/components/ChatScreen/MentionPanel.tsx
Normal file
102
src/screens/message/components/ChatScreen/MentionPanel.tsx
Normal 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;
|
||||
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal file
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal 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;
|
||||
42
src/screens/message/components/ChatScreen/MorePanel.tsx
Normal file
42
src/screens/message/components/ChatScreen/MorePanel.tsx
Normal 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;
|
||||
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal file
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal 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;
|
||||
169
src/screens/message/components/ChatScreen/constants.ts
Normal file
169
src/screens/message/components/ChatScreen/constants.ts
Normal 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;
|
||||
32
src/screens/message/components/ChatScreen/index.ts
Normal file
32
src/screens/message/components/ChatScreen/index.ts
Normal 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';
|
||||
1167
src/screens/message/components/ChatScreen/styles.ts
Normal file
1167
src/screens/message/components/ChatScreen/styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
213
src/screens/message/components/ChatScreen/types.ts
Normal file
213
src/screens/message/components/ChatScreen/types.ts
Normal 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;
|
||||
}
|
||||
1242
src/screens/message/components/ChatScreen/useChatScreen.ts
Normal file
1242
src/screens/message/components/ChatScreen/useChatScreen.ts
Normal file
File diff suppressed because it is too large
Load Diff
409
src/screens/message/components/EmbeddedChat.tsx
Normal file
409
src/screens/message/components/EmbeddedChat.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* EmbeddedChat.tsx
|
||||
* 嵌入式聊天组件 - 用于桌面端双栏布局右侧
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto';
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||
import { extractTextFromSegments } from '../../../types/dto';
|
||||
|
||||
interface EmbeddedChatProps {
|
||||
conversation: ConversationResponse;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
||||
const navigation = useNavigation();
|
||||
const currentUser = useAuthStore(state => state.currentUser);
|
||||
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<MessageResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// 获取会话信息
|
||||
const isGroupChat = conversation.type === 'group';
|
||||
const chatTitle = isGroupChat
|
||||
? conversation.group?.name || '群聊'
|
||||
: conversation.participants?.[0]?.nickname || conversation.participants?.[0]?.username || '聊天';
|
||||
const chatAvatar = isGroupChat
|
||||
? conversation.group?.avatar
|
||||
: conversation.participants?.[0]?.avatar;
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await messageManager.fetchMessages(String(conversation.id));
|
||||
setMessages(messageManager.getMessages(String(conversation.id)));
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 加载消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conversation.id]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadMessages();
|
||||
|
||||
// 订阅消息事件
|
||||
const subscriber: MessageSubscriber = (event: MessageEvent) => {
|
||||
if (
|
||||
event.type === 'messages_updated' &&
|
||||
String(event.payload?.conversationId) === String(conversation.id)
|
||||
) {
|
||||
setMessages(event.payload.messages || []);
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = messageManager.subscribe(subscriber);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [conversation.id, loadMessages]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputText.trim() || sending) return;
|
||||
|
||||
const text = inputText.trim();
|
||||
setInputText('');
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
const segments: MessageSegment[] = [{ type: 'text', data: { text } }];
|
||||
await messageManager.sendMessage(String(conversation.id), segments);
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 发送消息失败:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [inputText, sending, conversation.id]);
|
||||
|
||||
// 导航到完整聊天页面
|
||||
const handleNavigateToFullChat = () => {
|
||||
if (isGroupChat && conversation.group) {
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
});
|
||||
} else {
|
||||
const currentUserId = currentUser?.id;
|
||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
userName: otherUser?.nickname || otherUser?.username,
|
||||
isGroupChat: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = ({ item }: { item: MessageResponse }) => {
|
||||
const isMe = String(item.sender_id) === String(currentUser?.id);
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
size={36}
|
||||
name={item.sender?.nickname || item.sender?.username}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||
{isGroupChat && !isMe && (
|
||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||
)}
|
||||
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
|
||||
{extractTextFromSegments(item.segments)}
|
||||
</Text>
|
||||
</View>
|
||||
{isMe && (
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Avatar source={chatAvatar} size={36} name={chatTitle} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{chatTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<View style={styles.messageList}>
|
||||
{loading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : messages.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={48} color="#DDD" />
|
||||
<Text style={styles.emptyText}>暂无消息</Text>
|
||||
<Text style={styles.emptySubtext}>发送第一条消息开始聊天</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={true}
|
||||
onLayout={() => {
|
||||
if (messages.length > 0) {
|
||||
flatListRef.current?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
<View style={styles.inputArea}>
|
||||
<View style={styles.inputRow}>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.textInput}
|
||||
placeholder="发送消息..."
|
||||
placeholderTextColor="#999"
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
multiline={false}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, (!inputText.trim() || sending) && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!inputText.trim() || sending}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: '#FFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E8E8E8',
|
||||
...shadows.sm,
|
||||
height: 56,
|
||||
},
|
||||
headerButton: {
|
||||
padding: spacing.xs,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerCenter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginLeft: spacing.sm,
|
||||
maxWidth: 200,
|
||||
},
|
||||
messageList: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: spacing.xs,
|
||||
fontSize: 14,
|
||||
color: '#BBB',
|
||||
},
|
||||
listContent: {
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
messageRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: spacing.md,
|
||||
maxWidth: screenWidth * 0.7,
|
||||
},
|
||||
messageRowLeft: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
messageRowRight: {
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: screenWidth * 0.5,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: 18,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
messageBubbleMe: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
messageBubbleOther: {
|
||||
backgroundColor: '#FFF',
|
||||
borderBottomLeftRadius: 4,
|
||||
...shadows.sm,
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
messageTextMe: {
|
||||
color: '#FFF',
|
||||
},
|
||||
messageTextOther: {
|
||||
color: '#333',
|
||||
},
|
||||
inputArea: {
|
||||
backgroundColor: '#FFF',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E8E8E8',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconButton: {
|
||||
padding: spacing.xs,
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
padding: 0,
|
||||
maxHeight: 100,
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
});
|
||||
155
src/screens/message/components/GroupRequestShared.tsx
Normal file
155
src/screens/message/components/GroupRequestShared.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../../theme';
|
||||
|
||||
interface GroupInfoSummaryCardProps {
|
||||
groupName?: string;
|
||||
groupAvatar?: string;
|
||||
groupNo?: string;
|
||||
groupDescription?: string;
|
||||
memberCountText?: string;
|
||||
}
|
||||
|
||||
export const GroupInfoSummaryCard: React.FC<GroupInfoSummaryCardProps> = ({
|
||||
groupName,
|
||||
groupAvatar,
|
||||
groupNo,
|
||||
groupDescription,
|
||||
memberCountText,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.headerCard}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar source={groupAvatar || ''} size={88} name={groupName} />
|
||||
<View style={styles.groupInfo}>
|
||||
<Text variant="h3" style={styles.groupName} numberOfLines={1}>{groupName || '群聊'}</Text>
|
||||
<View style={styles.groupMeta}>
|
||||
<MaterialCommunityIcons name="account-group" size={16} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{memberCountText || '- 人'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupNoText}>
|
||||
群号:{groupNo || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.secondary} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{groupDescription || '暂无群描述'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface DecisionFooterProps {
|
||||
canAction: boolean;
|
||||
submitting: boolean;
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
processedText?: string;
|
||||
}
|
||||
|
||||
export const DecisionFooter: React.FC<DecisionFooterProps> = ({
|
||||
canAction,
|
||||
submitting,
|
||||
onReject,
|
||||
onApprove,
|
||||
processedText = '该请求已处理',
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
if (canAction) {
|
||||
return (
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
|
||||
<TouchableOpacity style={[styles.btn, styles.reject]} onPress={onReject} disabled={submitting}>
|
||||
<Text variant="body" color={colors.error.main}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.approve]} onPress={onApprove} disabled={submitting}>
|
||||
{submitting ? <ActivityIndicator color="#fff" /> : <Text variant="body" color="#fff">同意</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={[styles.statusBox, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
|
||||
<Text variant="caption" color={colors.text.secondary}>{processedText}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
...shadows.sm,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
groupInfo: {
|
||||
marginLeft: spacing.lg,
|
||||
flex: 1,
|
||||
},
|
||||
groupName: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
groupMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
groupNoText: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
descriptionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
groupDesc: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
btn: {
|
||||
flex: 1,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reject: {
|
||||
marginRight: spacing.sm,
|
||||
backgroundColor: colors.error.light + '25',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.error.light,
|
||||
},
|
||||
approve: {
|
||||
marginLeft: spacing.sm,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
statusBox: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||
import { authService } from '../../../services/authService';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
||||
import { User } from '../../../types';
|
||||
|
||||
type MutualFollowSelectorModalProps = {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
confirmingText?: string;
|
||||
confirmLoading?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedUsers: User[]) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
|
||||
visible,
|
||||
title = '邀请成员',
|
||||
confirmText = '确认',
|
||||
confirmingText = '处理中...',
|
||||
confirmLoading = false,
|
||||
excludeUserIds = [],
|
||||
initialSelectedIds = [],
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
const [friendList, setFriendList] = useState<User[]>([]);
|
||||
const [friendLoading, setFriendLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentUserId = currentUser?.id;
|
||||
const excludeIdsKey = useMemo(() => excludeUserIds.join(','), [excludeUserIds]);
|
||||
const initialSelectedIdsKey = useMemo(() => initialSelectedIds.join(','), [initialSelectedIds]);
|
||||
const stableExcludeUserIds = useMemo(() => excludeUserIds, [excludeIdsKey]);
|
||||
const stableInitialSelectedIds = useMemo(() => initialSelectedIds, [initialSelectedIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!currentUserId) return;
|
||||
|
||||
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
||||
setSelectedIds(prev => {
|
||||
if (prev.size !== nextSelectedIds.size) return nextSelectedIds;
|
||||
for (const id of prev) {
|
||||
if (!nextSelectedIds.has(id)) return nextSelectedIds;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const loadMutualFollows = async () => {
|
||||
setFriendLoading(true);
|
||||
try {
|
||||
const excludedIdSet = new Set(stableExcludeUserIds);
|
||||
const [following, followers] = await Promise.all([
|
||||
authService.getFollowingList(currentUserId, 1, 100),
|
||||
authService.getFollowersList(currentUserId, 1, 100),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const followerIdSet = new Set(followers.map(user => user.id));
|
||||
const mutualFriends = following.filter(user => followerIdSet.has(user.id));
|
||||
const availableFriends = mutualFriends.filter(user => !excludedIdSet.has(user.id));
|
||||
setFriendList(availableFriends);
|
||||
} catch (error) {
|
||||
console.error('获取互关好友失败:', error);
|
||||
Alert.alert('错误', '获取互关好友失败');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadMutualFollows();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, currentUserId, stableExcludeUserIds, stableInitialSelectedIds]);
|
||||
|
||||
const toggleSelection = (user: User) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(user.id)) {
|
||||
next.delete(user.id);
|
||||
} else {
|
||||
next.add(user.id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedUsers = friendList.filter(user => selectedIds.has(user.id));
|
||||
onConfirm(selectedUsers);
|
||||
};
|
||||
|
||||
const renderFriendItem: ListRenderItem<User> = ({ item }) => {
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.friendItem, isSelected && styles.friendItemSelected]}
|
||||
onPress={() => toggleSelection(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<View style={styles.friendInfo}>
|
||||
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxSelected]}>
|
||||
{isSelected && (
|
||||
<MaterialCommunityIcons name="check" size={16} color={colors.background.paper} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.modalHeaderButton}>
|
||||
<Text variant="body" color={colors.text.secondary}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<Text variant="h3" style={styles.modalTitle}>{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
disabled={selectedIds.size === 0 || confirmLoading}
|
||||
style={styles.modalHeaderButton}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedIds.size === 0 || confirmLoading ? colors.text.disabled : colors.primary.main}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{confirmLoading ? confirmingText : confirmText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tipContainer}>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.tipText}>
|
||||
仅显示互相关注用户
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
已选择 {selectedIds.size} 人
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{friendLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<FlatList
|
||||
data={friendList}
|
||||
renderItem={renderFriendItem}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.friendListContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={(
|
||||
<EmptyState
|
||||
title="暂无可选择的互关好友"
|
||||
description="互相关注后才可邀请加入群聊"
|
||||
icon="account-plus-outline"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: borderRadius['2xl'],
|
||||
borderTopRightRadius: borderRadius['2xl'],
|
||||
height: '80%',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalHeaderButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 60,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
confirmButton: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
tipContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
tipText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
friendListContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
friendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
friendItemSelected: {
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
},
|
||||
friendInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default MutualFollowSelectorModal;
|
||||
14
src/screens/message/index.ts
Normal file
14
src/screens/message/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 消息模块导出
|
||||
*/
|
||||
|
||||
export { MessageListScreen } from './MessageListScreen';
|
||||
export { ChatScreen } from './ChatScreen';
|
||||
export { NotificationsScreen } from './NotificationsScreen';
|
||||
export { default as CreateGroupScreen } from './CreateGroupScreen';
|
||||
export { default as GroupInfoScreen } from './GroupInfoScreen';
|
||||
export { default as GroupMembersScreen } from './GroupMembersScreen';
|
||||
export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen';
|
||||
export { default as JoinGroupScreen } from './JoinGroupScreen';
|
||||
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
|
||||
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
|
||||
Reference in New Issue
Block a user