PC端的部分适配
This commit is contained in:
@@ -45,16 +45,36 @@ import {
|
||||
ChatHeader,
|
||||
MessageBubble,
|
||||
ChatInput,
|
||||
GroupInfoPanel,
|
||||
PANEL_HEIGHTS,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation<any>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
||||
useEffect(() => {
|
||||
if (isWideScreen) {
|
||||
// 导航到大屏幕模式的主界面首页
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'Main',
|
||||
params: {
|
||||
screen: 'HomeTab',
|
||||
params: { screen: 'Home' }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, [isWideScreen, navigation]);
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
@@ -93,6 +113,19 @@ export const ChatScreen: React.FC = () => {
|
||||
setShowImageViewer(false);
|
||||
};
|
||||
|
||||
// 群信息面板状态
|
||||
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
|
||||
|
||||
// 打开群信息面板
|
||||
const handleGroupInfoPress = useCallback(() => {
|
||||
setShowGroupInfoPanel(true);
|
||||
}, []);
|
||||
|
||||
// 关闭群信息面板
|
||||
const handleCloseGroupInfoPanel = useCallback(() => {
|
||||
setShowGroupInfoPanel(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
// 状态
|
||||
messages,
|
||||
@@ -125,6 +158,7 @@ export const ChatScreen: React.FC = () => {
|
||||
messageMap,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
conversationId,
|
||||
|
||||
// Refs
|
||||
flatListRef,
|
||||
@@ -222,6 +256,8 @@ export const ChatScreen: React.FC = () => {
|
||||
onBack={() => navigation.goBack()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
isWideScreen={isWideScreen}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -369,6 +405,33 @@ export const ChatScreen: React.FC = () => {
|
||||
onClose={handleCloseImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
|
||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||
<GroupInfoPanel
|
||||
visible={showGroupInfoPanel}
|
||||
groupId={routeGroupId ? String(routeGroupId) : undefined}
|
||||
groupInfo={groupInfo as any}
|
||||
conversationId={conversationId || undefined}
|
||||
currentUserId={currentUserId}
|
||||
isPinned={false}
|
||||
onClose={handleCloseGroupInfoPanel}
|
||||
onTogglePin={(pinned) => {
|
||||
// TODO: 实现置顶功能
|
||||
console.log('Toggle pin:', pinned);
|
||||
}}
|
||||
onLeaveGroup={() => {
|
||||
// TODO: 实现退出群聊功能
|
||||
console.log('Leave group');
|
||||
}}
|
||||
onInviteMembers={() => {
|
||||
// TODO: 实现邀请新成员功能
|
||||
console.log('Invite members');
|
||||
}}
|
||||
onViewAllMembers={() => {
|
||||
// TODO: 实现查看全部成员功能
|
||||
console.log('View all members');
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -215,6 +215,8 @@ const CreateGroupScreen: React.FC = () => {
|
||||
placeholder="请输入群名称"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={50}
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{groupName.length}/50
|
||||
@@ -238,6 +240,8 @@ const CreateGroupScreen: React.FC = () => {
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
||||
{groupDescription.length}/500
|
||||
|
||||
@@ -110,6 +110,8 @@ const JoinGroupScreen: React.FC = () => {
|
||||
style={styles.input}
|
||||
editable={!searching && !joining}
|
||||
autoCapitalize="none"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
|
||||
|
||||
@@ -40,6 +40,8 @@ import { RootStackParamList, MessageStackParamList } from '../../navigation/type
|
||||
import { getUserCache } from '../../services/database';
|
||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
@@ -140,7 +142,15 @@ export const MessageListScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp & MessageNavProp>();
|
||||
const isFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
||||
const { isMobile } = useResponsive();
|
||||
let tabBarHeight = 0;
|
||||
try {
|
||||
tabBarHeight = useBottomTabBarHeight();
|
||||
} catch (e) {
|
||||
// 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可
|
||||
tabBarHeight = 0;
|
||||
}
|
||||
const currentUserId = useAuthStore(state => state.currentUser?.id);
|
||||
const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount);
|
||||
|
||||
@@ -173,6 +183,30 @@ export const MessageListScreen: React.FC = () => {
|
||||
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||
|
||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
// 系统消息会话对象(用于选中状态)
|
||||
const systemMessageConversation: ConversationResponse = useMemo(() => ({
|
||||
id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||||
type: 'private',
|
||||
last_seq: 0,
|
||||
last_message: {
|
||||
id: '0',
|
||||
conversation_id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||||
sender_id: 'system',
|
||||
seq: 0,
|
||||
segments: [{ type: 'text', data: { text: '系统通知' } }],
|
||||
status: 'normal',
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
last_message_at: new Date().toISOString(),
|
||||
unread_count: systemUnreadCount,
|
||||
participants: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}), [systemUnreadCount]);
|
||||
|
||||
// 动画值
|
||||
const [scaleAnims] = useState(() =>
|
||||
Array.from({ length: 20 }, () => new Animated.Value(1))
|
||||
@@ -272,19 +306,19 @@ export const MessageListScreen: React.FC = () => {
|
||||
}),
|
||||
]).start(() => {
|
||||
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
||||
// 跳转到系统通知页面 - 使用正确的导航路径
|
||||
// 系统通知 - 根据屏幕宽度决定如何显示
|
||||
if (isWideScreen) {
|
||||
// 宽屏下直接导航到通知页面
|
||||
navigation.navigate('Notifications' as never);
|
||||
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
|
||||
setSelectedConversation(systemMessageConversation);
|
||||
setShowNotifications(true);
|
||||
} else {
|
||||
// 窄屏下使用 Tab 导航
|
||||
(navigation as any).navigate('Main', {
|
||||
screen: 'MessageTab',
|
||||
params: { screen: 'Notifications' }
|
||||
});
|
||||
// 窄屏下也使用内部状态显示通知页面
|
||||
setShowNotifications(true);
|
||||
}
|
||||
} else if (isWideScreen) {
|
||||
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
|
||||
// 同时关闭系统通知页面
|
||||
setShowNotifications(false);
|
||||
setSelectedConversation(conversation);
|
||||
} else if (conversation.type === 'group' && conversation.group) {
|
||||
// 群聊 - 窄屏下导航
|
||||
@@ -765,6 +799,9 @@ export const MessageListScreen: React.FC = () => {
|
||||
onChangeText={setSearchText}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText('')}>
|
||||
@@ -903,7 +940,10 @@ export const MessageListScreen: React.FC = () => {
|
||||
|
||||
{/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */}
|
||||
<View style={styles.chatArea}>
|
||||
{selectedConversation ? (
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
) : selectedConversation ? (
|
||||
// 显示选中会话的聊天内容
|
||||
<EmbeddedChat
|
||||
conversation={selectedConversation}
|
||||
@@ -927,16 +967,17 @@ export const MessageListScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{isSearchMode ? (
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面,传入 onBack 回调
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
) : isSearchMode ? (
|
||||
renderSearchMode()
|
||||
) : isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
{renderConversationList()}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
{renderConversationList()}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
renderConversationList()
|
||||
)
|
||||
renderConversationList()
|
||||
)}
|
||||
{renderActionMenu()}
|
||||
</SafeAreaView>
|
||||
@@ -1101,7 +1142,7 @@ const styles = StyleSheet.create({
|
||||
...shadows.sm,
|
||||
},
|
||||
conversationItemSelected: {
|
||||
backgroundColor: '#E3F2FD',
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } 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 { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
@@ -41,7 +42,7 @@ const MESSAGE_TYPES = [
|
||||
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 = () => {
|
||||
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
||||
const isFocused = useIsFocused();
|
||||
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
@@ -148,16 +149,12 @@ export const NotificationsScreen: React.FC = () => {
|
||||
}
|
||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
||||
|
||||
// 屏幕失去焦点时返回消息列表
|
||||
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
// 使用 setTimeout 确保在导航状态稳定后再执行
|
||||
const timer = setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
if (!isFocused && onBack) {
|
||||
onBack();
|
||||
}
|
||||
}, [isFocused, navigation]);
|
||||
}, [isFocused, onBack]);
|
||||
|
||||
// 筛选消息
|
||||
const filteredMessages = activeType === 'all'
|
||||
@@ -317,6 +314,32 @@ export const NotificationsScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染头部(包含返回按钮)
|
||||
const renderHeader = () => {
|
||||
// 大屏模式(>= lg 断点)隐藏返回按钮
|
||||
const shouldShowBackButton = onBack && !isWideScreen;
|
||||
|
||||
return (
|
||||
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
||||
{shouldShowBackButton ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
|
||||
系统通知
|
||||
</Text>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => (
|
||||
<EmptyState
|
||||
@@ -330,6 +353,8 @@ export const NotificationsScreen: React.FC = () => {
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
{/* 头部 - 大屏模式下隐藏返回按钮 */}
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
@@ -403,6 +428,8 @@ export const NotificationsScreen: React.FC = () => {
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<>
|
||||
{/* 头部 - 小屏模式下显示返回按钮 */}
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
@@ -516,6 +543,38 @@ const styles = StyleSheet.create({
|
||||
filterCount: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
// Header 样式
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
headerWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
backButtonPlaceholder: {
|
||||
width: 40,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
headerTitleWide: {
|
||||
fontSize: 20,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
@@ -22,10 +22,15 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
onBack,
|
||||
onTitlePress,
|
||||
onMorePress,
|
||||
onGroupInfoPress,
|
||||
isWideScreen: propIsWideScreen,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const { width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
// 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
const isWideScreenHook = useBreakpointGTE('lg');
|
||||
// 优先使用 props 传入的 isWideScreen,否则使用 hook
|
||||
const isWideScreen = propIsWideScreen !== undefined ? propIsWideScreen : isWideScreenHook;
|
||||
|
||||
// 合并样式
|
||||
const styles = useMemo(() => {
|
||||
@@ -67,12 +72,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
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>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.backButton} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<TouchableOpacity
|
||||
@@ -115,12 +125,22 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示三点菜单 */}
|
||||
{isWideScreen && isGroupChat && onGroupInfoPress ? (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onGroupInfoPress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -134,6 +134,9 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
blurOnSubmit={false}
|
||||
editable={!isDisabled}
|
||||
onFocus={onFocus}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
593
src/screens/message/components/ChatScreen/GroupInfoPanel.tsx
Normal file
593
src/screens/message/components/ChatScreen/GroupInfoPanel.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* GroupInfoPanel.tsx
|
||||
* 群信息侧边栏组件
|
||||
* 大屏幕模式下从右侧滑入显示
|
||||
* 实现手机端群信息界面的核心功能
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
Animated,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text } from '../../../../components/common';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../../theme';
|
||||
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
|
||||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { groupManager } from '../../../../stores/groupManager';
|
||||
import { groupService } from '../../../../services/groupService';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const PANEL_WIDTH = 360;
|
||||
|
||||
interface GroupInfoPanelProps {
|
||||
visible: boolean;
|
||||
groupId?: string;
|
||||
groupInfo: GroupResponse | null;
|
||||
conversationId?: string;
|
||||
isPinned?: boolean;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onTogglePin?: (pinned: boolean) => void;
|
||||
onLeaveGroup?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onViewAllMembers?: () => void;
|
||||
}
|
||||
|
||||
export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||||
visible,
|
||||
groupId,
|
||||
groupInfo,
|
||||
conversationId,
|
||||
isPinned,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onTogglePin,
|
||||
onLeaveGroup,
|
||||
onInviteMembers,
|
||||
onViewAllMembers,
|
||||
}) => {
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [announcements, setAnnouncements] = useState<GroupAnnouncementResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAllMembers, setShowAllMembers] = useState(false);
|
||||
const [allMembers, setAllMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [loadingMoreMembers, setLoadingMoreMembers] = useState(false);
|
||||
|
||||
// 加载群成员(初始只加载10个)
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groupManager.getMembers(groupId, 1, 10);
|
||||
setMembers(response.list || []);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载成员失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 加载全部群成员
|
||||
const loadAllMembers = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
setLoadingMoreMembers(true);
|
||||
const response = await groupManager.getMembers(groupId, 1, 100);
|
||||
setAllMembers(response.list || []);
|
||||
setShowAllMembers(true);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载全部成员失败:', error);
|
||||
} finally {
|
||||
setLoadingMoreMembers(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 加载群公告
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
const response = await groupService.getAnnouncements(groupId, 1, 10);
|
||||
setAnnouncements(response.list || []);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载公告失败:', error);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 每次打开面板时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShowAllMembers(false);
|
||||
setAllMembers([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && groupId) {
|
||||
loadMembers();
|
||||
loadAnnouncements();
|
||||
}
|
||||
}, [visible, groupId, loadMembers, loadAnnouncements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: PANEL_WIDTH,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideAnim, fadeAnim]);
|
||||
|
||||
if (!isWideScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取加群方式文本
|
||||
const getJoinTypeText = (joinType?: number): string => {
|
||||
switch (joinType) {
|
||||
case 0:
|
||||
return '允许任何人加入';
|
||||
case 1:
|
||||
return '需要管理员审批';
|
||||
case 2:
|
||||
return '不允许任何人加入';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
{visible && (
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.overlayTouchable}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 侧边栏面板 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.panel,
|
||||
{
|
||||
transform: [{ translateX: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>群信息</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 群头像和名称 */}
|
||||
<View style={styles.groupInfoSection}>
|
||||
<Avatar
|
||||
source={groupInfo?.avatar}
|
||||
size={80}
|
||||
name={groupInfo?.name || '群聊'}
|
||||
/>
|
||||
<Text style={styles.groupName}>{groupInfo?.name || '群聊'}</Text>
|
||||
<Text style={styles.memberCount}>
|
||||
{groupInfo?.member_count || members.length || 0} 位成员
|
||||
</Text>
|
||||
{groupInfo?.join_type !== undefined && (
|
||||
<Text style={styles.joinType}>
|
||||
{getJoinTypeText(groupInfo.join_type)}
|
||||
</Text>
|
||||
)}
|
||||
{/* 邀请新成员按钮 */}
|
||||
{onInviteMembers && (
|
||||
<TouchableOpacity
|
||||
style={styles.inviteButton}
|
||||
onPress={onInviteMembers}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-plus" size={18} color={colors.primary.main} />
|
||||
<Text style={styles.inviteButtonText}>邀请新成员</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 分割线 */}
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* 群公告 */}
|
||||
{announcements.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||||
<Text style={styles.sectionTitle}>群公告</Text>
|
||||
</View>
|
||||
<View style={styles.noticeBox}>
|
||||
<Text style={styles.noticeText}>{announcements[0].content}</Text>
|
||||
<Text style={styles.noticeTime}>
|
||||
{new Date(announcements[0].created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 群简介 */}
|
||||
{groupInfo?.description && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>群简介</Text>
|
||||
<View style={styles.descriptionBox}>
|
||||
<Text style={styles.description}>{groupInfo.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 群成员列表 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.memberListHeader}>
|
||||
<Text style={styles.sectionTitle}>成员列表 ({groupInfo?.member_count || members.length})</Text>
|
||||
{onViewAllMembers && !showAllMembers && members.length >= 10 && (
|
||||
<TouchableOpacity onPress={loadAllMembers} style={styles.viewAllButton} disabled={loadingMoreMembers}>
|
||||
<Text style={styles.viewAllText}>
|
||||
{loadingMoreMembers ? '加载中...' : '查看全部'}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onViewAllMembers && showAllMembers && (
|
||||
<TouchableOpacity onPress={() => setShowAllMembers(false)} style={styles.viewAllButton}>
|
||||
<Text style={styles.viewAllText}>收起</Text>
|
||||
<MaterialCommunityIcons name="chevron-up" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.memberList}>
|
||||
{(showAllMembers ? allMembers : members).map((member) => (
|
||||
<View key={member.id || member.user_id} style={styles.memberItem}>
|
||||
<Avatar
|
||||
source={member.user?.avatar}
|
||||
size={44}
|
||||
name={member.nickname || member.user?.nickname || '用户'}
|
||||
/>
|
||||
<View style={styles.memberInfo}>
|
||||
<Text style={styles.memberName} numberOfLines={1}>
|
||||
{member.nickname || member.user?.nickname || '用户'}
|
||||
</Text>
|
||||
{member.role === 'owner' && (
|
||||
<Text style={styles.ownerBadge}>群主</Text>
|
||||
)}
|
||||
{member.role === 'admin' && (
|
||||
<Text style={styles.adminBadge}>管理员</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 群信息 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>群信息</Text>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>群号</Text>
|
||||
<Text style={styles.infoValue}>{groupInfo?.id || '-'}</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>创建时间</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{groupInfo?.created_at
|
||||
? new Date(groupInfo.created_at).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>全员禁言</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{groupInfo?.mute_all ? '已开启' : '已关闭'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.actionSection}>
|
||||
{conversationId && onTogglePin && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onTogglePin(!isPinned)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isPinned ? "pin-off" : "pin"}
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
<Text style={styles.actionButtonText}>
|
||||
{isPinned ? '取消置顶' : '置顶群聊'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onLeaveGroup && (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.leaveButton]}
|
||||
onPress={onLeaveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="logout"
|
||||
size={22}
|
||||
color={colors.error.main}
|
||||
/>
|
||||
<Text style={[styles.actionButtonText, styles.leaveButtonText]}>
|
||||
退出群聊
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 100,
|
||||
},
|
||||
overlayTouchable: {
|
||||
flex: 1,
|
||||
},
|
||||
panel: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: PANEL_WIDTH,
|
||||
backgroundColor: '#FFF',
|
||||
zIndex: 101,
|
||||
...shadows.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E8E8E8',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
groupInfoSection: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
groupName: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
memberCount: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#999',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
joinType: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
borderRadius: 20,
|
||||
marginTop: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light,
|
||||
},
|
||||
inviteButtonText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E8E8E8',
|
||||
marginHorizontal: spacing.md,
|
||||
},
|
||||
section: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
noticeBox: {
|
||||
backgroundColor: '#FFF9E6',
|
||||
padding: spacing.md,
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.warning.main,
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
noticeTime: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: '#999',
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'right',
|
||||
},
|
||||
descriptionBox: {
|
||||
backgroundColor: '#F5F7FA',
|
||||
padding: spacing.md,
|
||||
borderRadius: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
memberList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
memberListHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
memberInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
memberName: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
ownerBadge: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.warning.main,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: colors.warning.light + '30',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
adminBadge: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
moreMembers: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#999',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#333',
|
||||
},
|
||||
actionSection: {
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
borderRadius: 12,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
leaveButton: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
borderColor: colors.error.light,
|
||||
},
|
||||
leaveButtonText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupInfoPanel;
|
||||
@@ -130,9 +130,22 @@ const ImageSegment: React.FC<{
|
||||
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const imageUrl = data.thumbnail_url || data.url;
|
||||
|
||||
// 如果没有有效的图片URL,显示图片占位符
|
||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
setLoadError(false);
|
||||
|
||||
// 如果没有有效的图片URL,设置默认尺寸用于显示占位符
|
||||
if (!hasValidUrl) {
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经有宽高信息,直接使用
|
||||
if (data.width && data.height) {
|
||||
const aspectRatio = data.width / data.height;
|
||||
@@ -191,7 +204,7 @@ const ImageSegment: React.FC<{
|
||||
);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [imageUrl, data.width, data.height]);
|
||||
}, [imageUrl, data.width, data.height, hasValidUrl]);
|
||||
|
||||
// 记录按压位置
|
||||
const handlePressIn = (event: GestureResponderEvent) => {
|
||||
@@ -204,6 +217,35 @@ const ImageSegment: React.FC<{
|
||||
onImageLongPress?.(pressPositionRef.current);
|
||||
};
|
||||
|
||||
// 没有有效URL或加载失败时显示占位符
|
||||
if (!hasValidUrl || loadError) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url || Math.random()}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => hasValidUrl && 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)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons name="image" size={32} color="#999" />
|
||||
<Text style={{ color: '#999', fontSize: 12, marginTop: 4 }}>图片</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// 初始加载时显示占位
|
||||
if (!dimensions) {
|
||||
return (
|
||||
@@ -248,6 +290,9 @@ const ImageSegment: React.FC<{
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
priority="normal"
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export { LongPressMenu } from './LongPressMenu';
|
||||
export { ChatHeader } from './ChatHeader';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { GroupInfoPanel } from './GroupInfoPanel';
|
||||
|
||||
// Segment 渲染组件
|
||||
export {
|
||||
|
||||
@@ -322,13 +322,18 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
inputContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.divider}88`,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: 24,
|
||||
marginHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
...shadows.lg,
|
||||
// 移除明显的阴影效果,改用更微妙的边框
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
inputInner: {
|
||||
flexDirection: 'row',
|
||||
@@ -338,6 +343,12 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 4,
|
||||
minHeight: 44,
|
||||
// 移除阴影效果
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
inputInnerMuted: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
@@ -395,6 +406,12 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
maxHeight: 100,
|
||||
justifyContent: 'center',
|
||||
// 移除阴影
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
input: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -163,6 +163,10 @@ export interface ChatHeaderProps {
|
||||
onBack: () => void;
|
||||
onTitlePress: () => void;
|
||||
onMorePress: () => void;
|
||||
/** 大屏幕下显示群信息面板的回调 */
|
||||
onGroupInfoPress?: () => void;
|
||||
/** 是否为大屏幕模式 */
|
||||
isWideScreen?: boolean;
|
||||
}
|
||||
|
||||
// 回复预览 Props
|
||||
|
||||
@@ -14,26 +14,40 @@ import {
|
||||
Platform,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto';
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
||||
import { Avatar, Text, ImageGallery } from '../../../components/common';
|
||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||
import { extractTextFromSegments } from '../../../types/dto';
|
||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||
import { messageService } from '../../../services';
|
||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const PANEL_WIDTH = 360;
|
||||
|
||||
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);
|
||||
|
||||
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 使用 markAsRead hook
|
||||
const { markAsRead } = useMarkAsRead(String(conversation.id));
|
||||
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<MessageResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -42,6 +56,61 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// 图片预览状态
|
||||
const [imageGalleryVisible, setImageGalleryVisible] = useState(false);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [galleryImages, setGalleryImages] = useState<{ url: string }[]>([]);
|
||||
|
||||
// 右键菜单状态
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<MenuPosition | undefined>();
|
||||
const [selectedMessageForMenu, setSelectedMessageForMenu] = useState<GroupMessage | null>(null);
|
||||
|
||||
// 回复状态
|
||||
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
||||
|
||||
// 群信息面板动画
|
||||
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
|
||||
|
||||
// 群成员列表
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||
|
||||
// 打开/关闭群信息面板
|
||||
const toggleGroupInfoPanel = useCallback(() => {
|
||||
if (showGroupInfoPanel) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: PANEL_WIDTH,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setShowGroupInfoPanel(false);
|
||||
});
|
||||
} else {
|
||||
setShowGroupInfoPanel(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [showGroupInfoPanel, slideAnim, fadeAnim]);
|
||||
|
||||
// 获取会话信息
|
||||
const isGroupChat = conversation.type === 'group';
|
||||
const chatTitle = isGroupChat
|
||||
@@ -88,6 +157,18 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
};
|
||||
}, [conversation.id, loadMessages]);
|
||||
|
||||
// 自动标记已读 - 当有新消息时
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const maxSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||
if (maxSeq > 0) {
|
||||
markAsRead(maxSeq).catch(error => {
|
||||
console.error('[EmbeddedChat] 自动标记已读失败:', error);
|
||||
});
|
||||
}
|
||||
}, [messages, markAsRead]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputText.trim() || sending) return;
|
||||
@@ -127,32 +208,193 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片点击预览
|
||||
const handleImagePress = (images: { url: string }[], index: number) => {
|
||||
setGalleryImages(images);
|
||||
setCurrentImageIndex(index);
|
||||
setImageGalleryVisible(true);
|
||||
};
|
||||
|
||||
// 处理右键点击消息
|
||||
const handleMessageRightPress = (message: MessageResponse, event: any) => {
|
||||
// 获取鼠标右键位置
|
||||
const pageX = event?.nativeEvent?.pageX || 0;
|
||||
const pageY = event?.nativeEvent?.pageY || 0;
|
||||
|
||||
setSelectedMessageForMenu(message as GroupMessage);
|
||||
setContextMenuPosition({
|
||||
x: pageX,
|
||||
y: pageY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pressX: pageX,
|
||||
pressY: pageY,
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// 关闭右键菜单
|
||||
const handleContextMenuClose = () => {
|
||||
setContextMenuVisible(false);
|
||||
setSelectedMessageForMenu(null);
|
||||
setContextMenuPosition(undefined);
|
||||
};
|
||||
|
||||
// 处理回复消息
|
||||
const handleReply = (message: GroupMessage) => {
|
||||
setReplyingToMessage(message);
|
||||
handleContextMenuClose();
|
||||
};
|
||||
|
||||
// 处理撤回消息
|
||||
const handleRecall = async (messageId: string) => {
|
||||
try {
|
||||
await messageService.recallMessage(messageId);
|
||||
handleContextMenuClose();
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 撤回消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除消息
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
try {
|
||||
await messageService.deleteMessage(messageId);
|
||||
handleContextMenuClose();
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 删除消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息内容
|
||||
const renderMessageContent = (item: MessageResponse) => {
|
||||
// 撤回消息
|
||||
if (item.status === 'recalled') {
|
||||
return (
|
||||
<Text style={[styles.messageText, styles.messageTextRecalled]}>
|
||||
消息已撤回
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取消息 segments
|
||||
const segments = item.segments || [];
|
||||
|
||||
// 检查是否有图片消息
|
||||
const imageSegments = segments
|
||||
.filter(s => s.type === 'image')
|
||||
.map(s => s.data as ImageSegmentData);
|
||||
|
||||
// 提取文本内容
|
||||
const textContent = extractTextFromSegments(segments);
|
||||
|
||||
// 渲染多个内容(文本 + 图片)
|
||||
return (
|
||||
<View>
|
||||
{/* 文本内容 */}
|
||||
{textContent && textContent !== '[图片]' && (
|
||||
<Text style={[styles.messageText, item.sender_id === String(currentUser?.id) ? styles.messageTextMe : styles.messageTextOther]}>
|
||||
{textContent}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 图片内容 */}
|
||||
{imageSegments.length > 0 && (
|
||||
<View style={styles.imageGrid}>
|
||||
{imageSegments.map((imgData, idx) => {
|
||||
const imageUrl = imgData.thumbnail_url || imgData.url;
|
||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||
|
||||
if (!hasValidUrl) {
|
||||
// 无效URL显示占位符
|
||||
return (
|
||||
<View key={`img-${item.id}-${idx}`} style={styles.imagePlaceholder}>
|
||||
<MaterialCommunityIcons name="image" size={24} color="#999" />
|
||||
<Text style={styles.imagePlaceholderText}>图片</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算图片尺寸
|
||||
let width = 180;
|
||||
let height = 180;
|
||||
if (imgData.width && imgData.height) {
|
||||
const aspectRatio = imgData.width / imgData.height;
|
||||
if (aspectRatio > 1) {
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`img-${item.id}-${idx}`}
|
||||
onPress={() => handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: imageUrl }}
|
||||
style={[styles.messageImage, { width, height: Math.round(height) }]}
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 如果没有文本也没有图片,显示空消息提示 */}
|
||||
{!textContent && imageSegments.length === 0 && (
|
||||
<Text style={[styles.messageText, styles.messageTextRecalled, { opacity: 0.5 }]}>
|
||||
[消息格式错误]
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = ({ item }: { item: MessageResponse }) => {
|
||||
const isMe = String(item.sender_id) === String(currentUser?.id);
|
||||
|
||||
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||
const handlePointerDown = (e: any) => {
|
||||
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
||||
const isRightClick = e.nativeEvent?.button === 2;
|
||||
if (isRightClick) {
|
||||
// 阻止默认右键菜单
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
handleMessageRightPress(item, e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}>
|
||||
<View
|
||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||
// @ts-ignore - React Native for Web 支持 pointer events
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
size={36}
|
||||
name={item.sender?.nickname || item.sender?.username}
|
||||
<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]}>
|
||||
{item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
|
||||
</Text>
|
||||
{renderMessageContent(item)}
|
||||
</View>
|
||||
{isMe && (
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -163,9 +405,14 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.headerButton} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Avatar source={chatAvatar} size={36} name={chatTitle} />
|
||||
@@ -174,9 +421,16 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
|
||||
{isWideScreen && isGroupChat ? (
|
||||
<TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -231,6 +485,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -252,6 +509,96 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||
{isWideScreen && isGroupChat && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
{showGroupInfoPanel && (
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
opacity: fadeAnim,
|
||||
zIndex: 100,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
onPress={toggleGroupInfoPanel}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 侧边栏面板 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: PANEL_WIDTH,
|
||||
backgroundColor: '#FFF',
|
||||
zIndex: 101,
|
||||
...shadows.lg,
|
||||
transform: [{ translateX: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GroupInfoPanel
|
||||
visible={showGroupInfoPanel}
|
||||
groupId={conversation.group ? String(conversation.group.id) : undefined}
|
||||
groupInfo={conversation.group || null}
|
||||
currentUserId={currentUser?.id}
|
||||
onClose={toggleGroupInfoPanel}
|
||||
onTogglePin={(pinned) => {
|
||||
// TODO: 实现置顶功能
|
||||
console.log('Toggle pin:', pinned);
|
||||
}}
|
||||
onLeaveGroup={() => {
|
||||
// TODO: 实现退出群聊功能
|
||||
console.log('Leave group');
|
||||
}}
|
||||
onInviteMembers={() => {
|
||||
// TODO: 实现邀请新成员功能
|
||||
console.log('Invite members');
|
||||
}}
|
||||
onViewAllMembers={() => {
|
||||
// TODO: 实现查看全部成员功能
|
||||
console.log('View all members');
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
<LongPressMenu
|
||||
visible={contextMenuVisible}
|
||||
message={selectedMessageForMenu}
|
||||
currentUserId={currentUser?.id || ''}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
onReply={handleReply}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDeleteMessage}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={imageGalleryVisible}
|
||||
images={galleryImages.map((img, idx) => ({
|
||||
id: String(idx),
|
||||
url: img.url || ''
|
||||
}))}
|
||||
initialIndex={currentImageIndex}
|
||||
onClose={() => setImageGalleryVisible(false)}
|
||||
enableSave
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -328,7 +675,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
messageRowRight: {
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row-reverse',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: screenWidth * 0.5,
|
||||
@@ -387,6 +734,12 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
// 移除阴影
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 15,
|
||||
@@ -406,4 +759,33 @@ const styles = StyleSheet.create({
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
messageTextRecalled: {
|
||||
fontStyle: 'italic',
|
||||
color: '#999',
|
||||
},
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 4,
|
||||
},
|
||||
messageImage: {
|
||||
borderRadius: 8,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user