双端适配,web端的修改父容器和接口

This commit is contained in:
2026-03-29 02:34:13 +08:00
parent 3c071957ce
commit ebb0c003e3
16 changed files with 1419 additions and 226 deletions

View File

@@ -23,6 +23,29 @@ const devUpdatesUrl =
const expo = appJson.expo;
// 检测是否为 Web 平台
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
// 原生平台特有的插件(在 Web 端会报错)
const nativeOnlyPlugins = [
'expo-camera',
'expo-notifications',
'expo-background-fetch',
'expo-media-library',
'expo-image-picker',
'expo-video',
'expo-sqlite',
'./plugins/withMainActivityConfigChange',
];
// 过滤插件Web 端排除原生插件
const filteredPlugins = isWeb
? expo.plugins.filter((plugin) => {
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
return !nativeOnlyPlugins.includes(pluginName);
})
: expo.plugins;
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
@@ -40,10 +63,27 @@ module.exports = {
...expo.android,
package: 'skin.carrot.bbs',
},
// Web 端使用过滤后的插件
plugins: filteredPlugins,
extra: {
...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
},
// Web 端配置
...(isWeb && {
web: {
...expo.web,
build: {
...expo.web?.build,
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
],
},
},
},
}),
};

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "carrot_bbs",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "carrot_bbs",
"version": "1.0.1",
"version": "1.0.2",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",

View File

@@ -7,8 +7,8 @@ import {
Alert,
Modal,
Dimensions,
Platform,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs';
@@ -133,22 +133,74 @@ function createQrScannerStyles(colors: AppColors) {
fontSize: fontSizes.md,
fontWeight: '600',
},
webNotSupportedContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
backgroundColor: '#000',
},
webNotSupportedText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
lineHeight: 24,
},
});
}
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
// Web 端不支持扫码组件
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.webNotSupportedContainer}>
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.webNotSupportedText}>
使{'\n'}
App
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// 原生端扫码组件
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter();
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
const [permission, requestPermission] = useCameraPermissions();
// 动态导入 expo-camera避免 Web 端加载
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
const [permission, setPermission] = useState<any>(null);
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (visible) {
setScanned(false);
if (!permission?.granted) {
requestPermission();
}
if (visible && Platform.OS !== 'web') {
import('expo-camera').then((module) => {
setCameraModule(module);
const [perm, requestPerm] = module.useCameraPermissions();
setPermission(perm);
if (!perm?.granted) {
requestPerm();
}
});
}
}, [visible]);
@@ -178,7 +230,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
}
};
if (!permission?.granted) {
if (!cameraModule || !permission?.granted) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -192,7 +244,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
<View style={styles.permissionContainer}>
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.permissionText}></Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<TouchableOpacity
style={styles.permissionButton}
onPress={() => cameraModule?.useCameraPermissions()[1]()}
>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
@@ -201,6 +256,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
);
}
const { CameraView } = cameraModule;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -243,4 +300,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
);
};
// 根据平台导出不同组件
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
? QRCodeScannerWeb
: QRCodeScannerNative;
export default QRCodeScanner;

View File

@@ -0,0 +1,160 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall);
const isMinimized = callStore((s) => s.isMinimized);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '通话功能暂不支持网页端';
case 'connecting':
return '通话功能暂不支持网页端';
case 'connected':
return '通话功能暂不支持网页端';
case 'ending':
return '通话已结束';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
</View>
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -18,7 +18,7 @@ import {
type AppColors,
} from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { Text, ResponsiveContainer } from '../../components/common';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
type AppItem = {
@@ -46,6 +46,9 @@ const APP_ENTRIES: AppItem[] = [
},
];
// 应用卡片最大宽度设置
const APP_CARD_MAX_WIDTH = 720;
export const AppsScreen: React.FC = () => {
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
@@ -63,6 +66,9 @@ export const AppsScreen: React.FC = () => {
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
maxWidth: APP_CARD_MAX_WIDTH,
alignSelf: 'center' as const,
width: '100%' as const,
}),
[colors]
);
@@ -136,28 +142,16 @@ export const AppsScreen: React.FC = () => {
</View>
</View>
{isWideScreen ? (
<ResponsiveContainer maxWidth={720}>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
{renderTiles()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
>
{renderTiles()}
</ScrollView>
)}
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
>
{renderTiles()}
</ScrollView>
</SafeAreaView>
);
};

View File

@@ -24,8 +24,11 @@ import {
type AppColors,
} from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { Text, ResponsiveContainer, Loading } from '../../components/common';
import { Text, Loading } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
// 学科卡片最大宽度设置
const SUBJECT_CARD_MAX_WIDTH = 720;
import type { MaterialSubject } from '../../types/material';
import { materialRepository } from '../../data/repositories/MaterialRepository';
@@ -87,6 +90,9 @@ export const MaterialsScreen: React.FC = () => {
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
maxWidth: SUBJECT_CARD_MAX_WIDTH,
alignSelf: 'center' as const,
width: '100%' as const,
}),
[colors]
);
@@ -177,42 +183,23 @@ export const MaterialsScreen: React.FC = () => {
</View>
{/* Content */}
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary.main}
/>
}
>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary.main}
/>
}
>
{renderContent()}
</ScrollView>
)}
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={colors.primary.main}
/>
}
>
{renderContent()}
</ScrollView>
</SafeAreaView>
);
};

View File

@@ -25,8 +25,11 @@ import {
type AppColors,
} from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { Text, ResponsiveContainer, Loading } from '../../components/common';
import { Text, Loading } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
// 资料卡片最大宽度
const MATERIAL_CARD_MAX_WIDTH = 720;
import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material';
import {
materialRepository,
@@ -120,6 +123,9 @@ export const SubjectMaterialsScreen: React.FC = () => {
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 1,
maxWidth: MATERIAL_CARD_MAX_WIDTH,
alignSelf: 'center' as const,
width: '100%' as const,
}),
[colors]
);
@@ -266,17 +272,9 @@ export const SubjectMaterialsScreen: React.FC = () => {
</View>
{/* Content */}
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</View>
</ResponsiveContainer>
) : (
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</View>
)}
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</View>
</SafeAreaView>
);
};

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
@@ -18,11 +18,11 @@ import { useAppColors, spacing } from '../../../../theme';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
// 表情尺寸配置
// 表情尺寸配置 - 固定宽度 40px使用 flex 布局
const EMOJI_SIZES = {
mobile: { size: 28, itemHeight: 52 },
tablet: { size: 32, itemHeight: 60 },
desktop: { size: 36, itemHeight: 68 },
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
tablet: { size: 26, itemWidth: 40, itemHeight: 40 },
desktop: { size: 28, itemWidth: 40, itemHeight: 40 },
};
// Tab 类型
@@ -62,29 +62,30 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
return EMOJI_SIZES.mobile;
}, [isWideScreen, isTablet]);
// 表情网格列数
const emojiColumns = useMemo(() => {
if (isWideScreen) return 10;
if (isTablet) return 9;
return 8;
}, [isWideScreen, isTablet]);
// 合并样式
// 合并样式 - 使用 flex 布局,每个表情固定 40px 宽度
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
emojiContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: spacing.sm,
},
emojiItem: {
...baseStyles.emojiItem,
width: `${100 / emojiColumns}%`,
height: emojiSize.itemHeight,
width: 40,
height: 40,
margin: 10,
alignItems: 'center',
justifyContent: 'center',
},
emojiText: {
...baseStyles.emojiText,
fontSize: emojiSize.size,
lineHeight: emojiSize.size + 6,
lineHeight: emojiSize.size + 4,
},
});
}, [emojiSize, emojiColumns, baseStyles]);
}, [emojiSize, baseStyles]);
// 加载自定义表情
const loadStickers = useCallback(async () => {
@@ -106,37 +107,26 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
}
}, [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 虚拟化,降低首次打开卡顿)
// 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度)
const renderEmojiPanel = () => (
<FlatList
data={EMOJIS}
key={emojiColumns}
numColumns={emojiColumns}
keyExtractor={(item, index) => `${item}-${index}`}
renderItem={renderEmojiItem}
contentContainerStyle={styles.emojiGrid}
showsVerticalScrollIndicator={false}
<ScrollView
contentContainerStyle={styles.emojiContainer}
showsVerticalScrollIndicator={true}
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,
})}
/>
>
{EMOJIS.map((emoji, index) => (
<TouchableOpacity
key={`${emoji}-${index}`}
style={styles.emojiItem}
onPress={() => onInsertEmoji(emoji)}
activeOpacity={0.7}
>
<Text style={styles.emojiText}>{emoji}</Text>
</TouchableOpacity>
))}
</ScrollView>
);
// 添加表情(从相册选择)

View File

@@ -0,0 +1,602 @@
/**
* GroupInfoPanel.web.tsx
* Web端群信息侧边栏组件
* 大屏幕模式下从右侧滑入显示
* 实现手机端群信息界面的核心功能
*/
import React, { useEffect, useState, useCallback, useMemo } 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 { useAppColors, spacing, fontSizes, shadows, type AppColors } 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 colors = useAppColors();
const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]);
const isWideScreen = useBreakpointGTE('lg');
// Web 端不使用 useNativeDriver
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,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: PANEL_WIDTH,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
// Web 端不使用 useNativeDriver
useNativeDriver: false,
}),
]).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>
</>
);
};
function createGroupInfoPanelStyles(colors: AppColors) {
return 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: colors.background.paper,
zIndex: 101,
...shadows.lg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
headerTitle: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.text.primary,
},
closeButton: {
padding: spacing.xs,
},
content: {
flex: 1,
},
groupInfoSection: {
alignItems: 'center',
paddingVertical: spacing.xl,
},
groupName: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.text.primary,
marginTop: spacing.md,
},
memberCount: {
fontSize: fontSizes.md,
color: colors.text.secondary,
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: colors.divider,
marginHorizontal: spacing.md,
},
section: {
padding: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.xs,
},
noticeBox: {
backgroundColor: colors.chat.tipBg,
padding: spacing.md,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: colors.warning.main,
},
noticeText: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
lineHeight: 20,
},
noticeTime: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginTop: spacing.xs,
textAlign: 'right',
},
descriptionBox: {
backgroundColor: colors.chat.surfaceMuted,
padding: spacing.md,
borderRadius: 8,
},
description: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
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: colors.text.primary,
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: colors.text.secondary,
textAlign: 'center',
paddingVertical: spacing.sm,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.chat.borderHairline,
},
infoLabel: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
},
infoValue: {
fontSize: fontSizes.sm,
color: colors.text.primary,
},
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;

View File

@@ -23,6 +23,8 @@ import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../..
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { extractTextFromSegments } from '../../../types/dto';
import { useBreakpointGTE } from '../../../hooks/useResponsive';
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
@@ -30,15 +32,20 @@ import { messageService } from '../../../services';
import * as hrefs from '../../../navigation/hrefs';
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
import { EmojiPanel } from './ChatScreen/EmojiPanel';
import { MorePanel } from './ChatScreen/MorePanel';
const { width: screenWidth } = Dimensions.get('window');
const PANEL_WIDTH = 360;
// 时间分隔间隔(毫秒)- 5分钟
const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000;
function createEmbeddedChatStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.chat.screen,
backgroundColor: colors.background.default,
},
header: {
flexDirection: 'row',
@@ -78,7 +85,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
},
messageList: {
flex: 1,
backgroundColor: colors.chat.screen,
backgroundColor: colors.background.default,
},
centerContainer: {
flex: 1,
@@ -102,7 +109,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
alignItems: 'flex-start',
marginBottom: spacing.md,
maxWidth: screenWidth * 0.7,
},
@@ -112,6 +119,15 @@ function createEmbeddedChatStyles(colors: AppColors) {
messageRowRight: {
alignSelf: 'flex-end',
justifyContent: 'flex-end',
alignItems: 'flex-end',
},
avatarWrapper: {
marginHorizontal: 2,
},
messageContent: {
maxWidth: screenWidth * 0.5,
marginHorizontal: spacing.xs,
alignItems: 'flex-start',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
@@ -122,11 +138,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
},
messageBubbleMe: {
backgroundColor: colors.chat.replyTint,
borderBottomRightRadius: 4,
borderTopRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: colors.chat.bubbleIncoming,
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 },
shadowOpacity: 0.04,
@@ -134,9 +150,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
elevation: 1,
},
senderName: {
fontSize: 12,
fontSize: 14,
color: colors.text.secondary,
marginBottom: 2,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',
},
messageText: {
fontSize: 15,
@@ -149,7 +167,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
color: colors.chat.textPrimary,
},
inputArea: {
backgroundColor: colors.chat.screen,
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
paddingHorizontal: spacing.md,
@@ -227,6 +245,43 @@ function createEmbeddedChatStyles(colors: AppColors) {
fontSize: 12,
marginTop: 4,
},
// 时间分隔样式
timeContainer: {
alignItems: 'center',
marginVertical: spacing.md,
},
timeText: {
color: 'rgba(142, 142, 147, 0.95)',
fontSize: 11,
backgroundColor: 'rgba(142, 142, 147, 0.1)',
paddingHorizontal: spacing.sm + 4,
paddingVertical: 5,
borderRadius: 12,
fontWeight: '500',
overflow: 'hidden',
letterSpacing: 0.2,
},
// 系统通知样式(如"某某加入了群聊"
systemNoticeContainer: {
alignItems: 'center',
marginVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
systemNoticeText: {
fontSize: 12,
color: colors.chat.textSecondary,
backgroundColor: 'rgba(142, 142, 147, 0.12)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 12,
overflow: 'hidden',
},
// 面板容器样式
panelContainer: {
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
});
}
@@ -268,6 +323,81 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
// 回复状态
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
// 面板显示状态
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [showMorePanel, setShowMorePanel] = useState(false);
// 切换表情面板
const toggleEmojiPanel = useCallback(() => {
setShowEmojiPanel(prev => !prev);
setShowMorePanel(false);
if (!showEmojiPanel) {
inputRef.current?.blur();
}
}, [showEmojiPanel]);
// 切换更多功能面板
const toggleMorePanel = useCallback(() => {
setShowMorePanel(prev => !prev);
setShowEmojiPanel(false);
if (!showMorePanel) {
inputRef.current?.blur();
}
}, [showMorePanel]);
// 关闭所有面板
const closePanels = useCallback(() => {
setShowEmojiPanel(false);
setShowMorePanel(false);
}, []);
// 插入表情
const handleInsertEmoji = useCallback((emoji: string) => {
setInputText(prev => prev + emoji);
}, []);
// 发送自定义表情
const handleSendSticker = useCallback(async (stickerUrl: string) => {
if (!conversation.id) return;
try {
setSending(true);
const segments: MessageSegment[] = [{
type: 'image',
data: { url: stickerUrl, thumbnail_url: stickerUrl }
}];
await messageManager.sendMessage(String(conversation.id), segments);
closePanels();
} catch (error) {
console.error('[EmbeddedChat] 发送表情失败:', error);
} finally {
setSending(false);
}
}, [conversation.id, closePanels]);
// 处理更多功能
const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) {
case 'image':
// TODO: 实现选择图片功能
console.log('选择图片');
break;
case 'camera':
// TODO: 实现相机功能
console.log('相机');
break;
case 'file':
// TODO: 实现文件功能
console.log('文件');
break;
case 'location':
// TODO: 实现位置功能
console.log('位置');
break;
}
closePanels();
}, [closePanels]);
// 群信息面板动画
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
@@ -276,6 +406,42 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
// 群成员列表
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
// 格式化时间
const formatTime = useCallback((dateString: string): string => {
try {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
if (diffInHours < 24 && date.getDate() === now.getDate()) {
// 当天消息显示 "上午/下午 HH:mm" 格式
const hours = date.getHours();
const period = hours < 12 ? '上午' : '下午';
const displayHours = hours % 12 || 12;
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${period} ${displayHours}:${minutes}`;
}
return formatDistanceToNow(date, {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
}, []);
// 检查是否需要显示时间分隔
const shouldShowTime = useCallback((index: number): boolean => {
if (index === 0) return true;
const currentMsg = messages[index];
const prevMsg = messages[index - 1];
if (!currentMsg || !prevMsg) return false;
const currentTime = new Date(currentMsg.created_at).getTime();
const prevTime = new Date(prevMsg.created_at).getTime();
return (currentTime - prevTime) > TIME_SEPARATOR_INTERVAL;
}, [messages]);
// 打开/关闭群信息面板
const toggleGroupInfoPanel = useCallback(() => {
if (showGroupInfoPanel) {
@@ -325,8 +491,21 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
setLoading(true);
await messageManager.fetchMessages(String(conversation.id));
setMessages(messageManager.getMessages(String(conversation.id)));
} catch (error) {
console.error('[EmbeddedChat] 加载消息失败:', error);
} catch (error: any) {
// 检查是否是权限错误或会话不存在错误
const errorMessage = error?.message || String(error);
if (
errorMessage.includes('not found') ||
errorMessage.includes('no permission') ||
error?.code === 'NOT_FOUND' ||
error?.code === 'FORBIDDEN'
) {
console.warn('[EmbeddedChat] 会话不存在或无权限访问:', conversation.id);
// 设置空消息列表,让 UI 显示"暂无消息"而不是崩溃
setMessages([]);
} else {
console.error('[EmbeddedChat] 加载消息失败:', error);
}
} finally {
setLoading(false);
}
@@ -558,9 +737,36 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
};
// 渲染消息
const renderMessage = ({ item }: { item: MessageResponse }) => {
const renderMessage = ({ item, index }: { item: MessageResponse; index: number }) => {
const isMe = String(item.sender_id) === String(currentUser?.id);
const showTime = shouldShowTime(index);
// 系统通知消息特殊处理
// 1. is_system_notice 字段WebSocket 推送时设置)
// 2. category === 'notification'(从数据库加载时使用)
// 3. sender_id === '10000'(系统发送者兜底)
const isSystemNotice = (item as any).is_system_notice || item.category === 'notification' || item.sender_id === '10000';
// 系统通知消息渲染(如"某某加入了群聊"
if (isSystemNotice) {
return (
<View>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(item.created_at)}
</Text>
</View>
)}
<View style={styles.systemNoticeContainer}>
<Text style={styles.systemNoticeText}>
{(item as any).notice_content || extractTextFromSegments(item.segments)}
</Text>
</View>
</View>
);
}
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
const handlePointerDown = (e: any) => {
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
@@ -572,33 +778,55 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
handleMessageRightPress(item, e);
}
};
return (
<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}
/>
<View>
{/* 时间分隔 */}
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(item.created_at)}
</Text>
</View>
)}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
<View
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
// @ts-ignore - React Native for Web 支持 pointer events
onPointerDown={handlePointerDown}
>
{/* 对方头像(左侧) */}
{!isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={item.sender?.avatar}
size={36}
name={item.sender?.nickname || item.sender?.username}
/>
</View>
)}
{/* 消息内容区域 */}
<View style={styles.messageContent}>
{/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */}
{isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{renderMessageContent(item)}
</View>
</View>
{/* 自己的头像(右侧) */}
{isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
/>
</View>
)}
{renderMessageContent(item)}
</View>
{isMe && (
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
/>
)}
</View>
);
};
@@ -669,8 +897,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
>
<View style={styles.inputArea}>
<View style={styles.inputRow}>
<TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="plus-circle-outline" size={28} color={colors.text.secondary} />
<TouchableOpacity style={styles.iconButton} onPress={toggleMorePanel}>
<MaterialCommunityIcons
name={showMorePanel ? "close-circle-outline" : "plus-circle-outline"}
size={28}
color={showMorePanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<View style={styles.inputContainer}>
@@ -691,8 +923,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
/>
</View>
<TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="emoticon-outline" size={28} color={colors.text.secondary} />
<TouchableOpacity style={styles.iconButton} onPress={toggleEmojiPanel}>
<MaterialCommunityIcons
name={showEmojiPanel ? "emoticon-happy" : "emoticon-outline"}
size={28}
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity
@@ -708,6 +944,26 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</TouchableOpacity>
</View>
</View>
{/* 表情面板 */}
{showEmojiPanel && (
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
<EmojiPanel
onInsertEmoji={handleInsertEmoji}
onInsertSticker={handleSendSticker}
onClose={closePanels}
/>
</View>
)}
{/* 更多功能面板 */}
{showMorePanel && (
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
<MorePanel
onAction={handleMoreAction}
/>
</View>
)}
</KeyboardAvoidingView>
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}

View File

@@ -10,8 +10,11 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
import { useAuthStore } from '../../stores';
@@ -340,15 +343,13 @@ export const AccountSecurityScreen: React.FC = () => {
</>
);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
)}
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{content}
</ScrollView>
</SafeAreaView>
);
};
@@ -380,6 +381,9 @@ function createAccountSecurityStyles(colors: AppColors) {
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
padding: spacing.md,
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
statusRow: {
flexDirection: 'row',

View File

@@ -31,9 +31,12 @@ import {
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores';
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
import { Avatar, Button, Text } from '../../components/common';
import { authService, uploadService } from '../../services';
import { useResponsive } from '../../hooks';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
function createEditProfileStyles(colors: AppColors) {
return StyleSheet.create({
@@ -210,6 +213,9 @@ function createEditProfileStyles(colors: AppColors) {
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
sectionTitle: {
marginBottom: spacing.lg,
@@ -357,6 +363,7 @@ export const EditProfileScreen: React.FC = () => {
const navigation = useNavigation();
const { currentUser, updateUser } = useAuthStore();
const { isWideScreen, isMobile, width } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const insets = useSafeAreaInsets();
const [nickname, setNickname] = useState(currentUser?.nickname || '');
@@ -793,23 +800,12 @@ export const EditProfileScreen: React.FC = () => {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
)}
<ScrollView
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
showsVerticalScrollIndicator={false}
>
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);

View File

@@ -14,14 +14,17 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
import { Text, ResponsiveContainer } from '../../components/common';
import { Text } from '../../components/common';
import {
loadNotificationPreferences,
setPushNotificationsPreference,
setSoundPreference,
setVibrationPreference,
} from '../../services/notificationPreferences';
import { useResponsive } from '../../hooks';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
// 内容最大宽度
const CONTENT_MAX_WIDTH = 720;
interface NotificationSettingItem {
key: string;
@@ -168,19 +171,13 @@ export const NotificationSettingsScreen: React.FC = () => {
</>
);
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
)}
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</ScrollView>
</SafeAreaView>
);
};
@@ -212,6 +209,9 @@ function createNotificationSettingsStyles(colors: AppColors) {
marginHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
overflow: 'hidden',
maxWidth: CONTENT_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
settingItem: {
flexDirection: 'row',

View File

@@ -27,10 +27,13 @@ import {
type AppColors,
} from '../../theme';
import { useAuthStore } from '../../stores';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive } from '../../hooks';
import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
// 设置卡片最大宽度
const SETTINGS_CARD_MAX_WIDTH = 720;
import * as hrefs from '../../navigation/hrefs';
interface SettingsItem {
@@ -84,6 +87,9 @@ function createSettingsStyles(colors: AppColors) {
},
groupContainer: {
marginBottom: spacing.lg,
maxWidth: SETTINGS_CARD_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
groupHeader: {
flexDirection: 'row',
@@ -154,6 +160,9 @@ function createSettingsStyles(colors: AppColors) {
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
gap: spacing.sm,
maxWidth: SETTINGS_CARD_MAX_WIDTH,
alignSelf: 'center',
width: '100%',
},
logoutText: {
fontWeight: '500',
@@ -172,9 +181,9 @@ function createSettingsStyles(colors: AppColors) {
export const SettingsScreen: React.FC = () => {
const router = useRouter();
const { logout } = useAuthStore();
const { isWideScreen } = useResponsive();
const insets = useSafeAreaInsets();
const { isMobile } = useResponsive();
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
const colors = useAppColors();
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
const themePreference = useThemePreference();
@@ -355,17 +364,9 @@ export const SettingsScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
{renderContent()}
</ScrollView>
)}
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
{renderContent()}
</ScrollView>
</SafeAreaView>
);
};

View File

@@ -332,8 +332,22 @@ class MessageService {
page: data.page || 1,
page_size: data.page_size || 20,
};
} catch (error) {
console.error('获取消息列表失败:', error);
} catch (error: any) {
// 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志
const errorMessage = error?.message || String(error);
if (
errorMessage.includes('not found') ||
errorMessage.includes('no permission') ||
error?.code === 'NOT_FOUND' ||
error?.code === 'FORBIDDEN'
) {
// 会话不存在或无权限访问,返回空消息列表
// 这可能是因为会话已被删除、用户被踢出群聊等情况
console.warn('[MessageService] 会话不存在或无权限访问:', conversationId);
} else {
// 其他错误才打印错误日志
console.error('[MessageService] 获取消息列表失败:', error);
}
return {
messages: [],
total: 0,

View File

@@ -0,0 +1,89 @@
// WebRTCManager for Web platform (stub implementation)
import type {
ICEServer,
ConnectionState,
WebRTCManagerEvent,
} from './WebRTCManager';
type EventHandler = (event: WebRTCManagerEvent) => void;
class WebRTCManagerWeb {
private eventHandlers: Set<EventHandler> = new Set();
private disposed = false;
async initialize(iceServers: ICEServer[] = []): Promise<void> {
console.log('[WebRTC] WebRTC not supported on web platform');
}
async createLocalStream(voiceOnly = true): Promise<MediaStream | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createOffer(): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createAnswer(): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit | null> {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
setMuted(muted: boolean): void {
console.warn('[WebRTC] WebRTC not supported on web platform');
}
isMuted(): boolean {
console.warn('[WebRTC] WebRTC not supported on web platform');
return false;
}
getRemoteStream(): MediaStream | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
getLocalStream(): MediaStream | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
getPeerConnection(): RTCPeerConnection | null {
console.warn('[WebRTC] WebRTC not supported on web platform');
return null;
}
onEvent(handler: EventHandler): () => void {
console.warn('[WebRTC] WebRTC not supported on web platform');
return () => {
this.eventHandlers.delete(handler);
};
}
dispose(): void {
this.disposed = true;
this.eventHandlers.clear();
console.log('[WebRTC] WebRTC disposed on web platform');
}
}
export const webrtcManager = new WebRTCManagerWeb();