feat(message): add embedded mode to ChatScreen for desktop dual-column layout
Replace EmbeddedChat with unified ChatScreen supporting embedded mode through props. Pass conversation data, layout constraints, and navigation callbacks directly to ChatScreen instead of using separate embedded component. Consolidates dual-column layout logic into single component with conditional rendering based on isEmbedded prop.
This commit is contained in:
@@ -49,9 +49,10 @@ import {
|
||||
ChatInput,
|
||||
GroupInfoPanel,
|
||||
PANEL_HEIGHTS,
|
||||
ChatScreenProps,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -62,12 +63,12 @@ export const ChatScreen: React.FC = () => {
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页(嵌入式模式下不跳转)
|
||||
useEffect(() => {
|
||||
if (isWideScreen) {
|
||||
if (isWideScreen && !props.isEmbedded) {
|
||||
router.replace(hrefs.hrefHome());
|
||||
}
|
||||
}, [isWideScreen, router]);
|
||||
}, [isWideScreen, router, props.isEmbedded]);
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
@@ -88,20 +89,20 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
const containerStyle = useMemo(() => ([
|
||||
styles.container,
|
||||
isWideScreen ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.container]);
|
||||
isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.container, props.isEmbedded]);
|
||||
|
||||
const listContentStyle = useMemo(() => ([
|
||||
styles.listContent,
|
||||
isWideScreen
|
||||
isWideScreen && !props.isEmbedded
|
||||
? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const }
|
||||
: { paddingHorizontal: 16 },
|
||||
]), [isWideScreen, styles.listContent]);
|
||||
]), [isWideScreen, styles.listContent, props.isEmbedded]);
|
||||
|
||||
const inputWrapperStyle = useMemo(() => ([
|
||||
styles.inputWrapper,
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputWrapper]);
|
||||
isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputWrapper, props.isEmbedded]);
|
||||
|
||||
// 图片查看器状态
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
@@ -208,7 +209,7 @@ export const ChatScreen: React.FC = () => {
|
||||
handleReachLatestEdge,
|
||||
jumpToLatestMessages,
|
||||
setBrowsingHistory,
|
||||
} = useChatScreen();
|
||||
} = useChatScreen(props);
|
||||
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||
|
||||
const longPressMenuMemberMap = useMemo(() => {
|
||||
@@ -412,22 +413,22 @@ export const ChatScreen: React.FC = () => {
|
||||
}, [handleMessageListContentSizeChange]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={containerStyle} edges={['top']}>
|
||||
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
style={[styles.container, { overflow: 'hidden' }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />
|
||||
{!props.isEmbedded && <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />}
|
||||
|
||||
{/* 顶部栏 */}
|
||||
<ChatHeader
|
||||
isGroupChat={isGroupChat}
|
||||
groupInfo={groupInfo}
|
||||
otherUser={otherUser}
|
||||
routeGroupName={routeGroupName}
|
||||
routeGroupName={routeGroupName ?? undefined}
|
||||
typingHint={typingHint}
|
||||
onBack={() => router.back()}
|
||||
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
|
||||
@@ -54,8 +54,8 @@ import {
|
||||
import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||
// 导入 ChatScreen 组件用于桌面端双栏布局
|
||||
import { ChatScreen } from './ChatScreen';
|
||||
import { ConversationListRow } from './components/ConversationListRow';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
@@ -852,9 +852,13 @@ export const MessageListScreen: React.FC = () => {
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
) : selectedConversation ? (
|
||||
// 显示选中会话的聊天内容
|
||||
<EmbeddedChat
|
||||
conversation={selectedConversation}
|
||||
onBack={() => setSelectedConversation(null)}
|
||||
<ChatScreen
|
||||
isEmbedded
|
||||
embeddedConversationId={String(selectedConversation.id)}
|
||||
embeddedIsGroupChat={selectedConversation.type === 'group'}
|
||||
embeddedGroupId={selectedConversation.group ? String(selectedConversation.group.id) : undefined}
|
||||
embeddedGroupName={selectedConversation.group?.name}
|
||||
onEmbeddedBack={() => setSelectedConversation(null)}
|
||||
/>
|
||||
) : (
|
||||
// 默认占位符
|
||||
|
||||
@@ -51,6 +51,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
header: {
|
||||
...baseStyles.header,
|
||||
paddingHorizontal: isWideScreen ? spacing.xl : spacing.md,
|
||||
height: isWideScreen ? 60 : 52,
|
||||
},
|
||||
headerName: {
|
||||
...baseStyles.headerName,
|
||||
|
||||
@@ -62,7 +62,18 @@ export interface MenuItem {
|
||||
|
||||
// ChatScreen Props
|
||||
export interface ChatScreenProps {
|
||||
// 路由参数通过 useRoute 获取
|
||||
/** 嵌入式模式:外部传入的会话 ID(优先于路由参数) */
|
||||
embeddedConversationId?: string;
|
||||
/** 嵌入式模式:是否为群聊 */
|
||||
embeddedIsGroupChat?: boolean;
|
||||
/** 嵌入式模式:群组 ID */
|
||||
embeddedGroupId?: string;
|
||||
/** 嵌入式模式:群组名称 */
|
||||
embeddedGroupName?: string;
|
||||
/** 嵌入式模式:返回回调 */
|
||||
onEmbeddedBack?: () => void;
|
||||
/** 嵌入式模式:是否为嵌入式布局 */
|
||||
isEmbedded?: boolean;
|
||||
}
|
||||
|
||||
// 消息气泡 Props
|
||||
|
||||
@@ -40,11 +40,12 @@ import {
|
||||
ChatRouteParams,
|
||||
MenuPosition,
|
||||
PendingChatAttachment,
|
||||
ChatScreenProps,
|
||||
} from './types';
|
||||
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
|
||||
import { messageRepository } from '@/database';
|
||||
|
||||
export const useChatScreen = () => {
|
||||
export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
const getSendErrorMessage = useCallback((error: unknown, fallback: string) => {
|
||||
if (error instanceof ApiError && error.message) {
|
||||
return error.message;
|
||||
@@ -60,7 +61,17 @@ export const useChatScreen = () => {
|
||||
groupName?: string | string[];
|
||||
}>();
|
||||
// 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失
|
||||
// 嵌入式模式下优先使用 props,否则从路由参数获取
|
||||
const routeParams = useMemo(() => {
|
||||
if (props?.embeddedConversationId) {
|
||||
return {
|
||||
conversationId: props.embeddedConversationId,
|
||||
userId: null as string | null,
|
||||
isGroupChat: props.embeddedIsGroupChat ?? false,
|
||||
groupId: props.embeddedGroupId ?? null,
|
||||
groupName: props.embeddedGroupName ?? null,
|
||||
};
|
||||
}
|
||||
const isGroupFlag = firstRouteParam(rawParams.isGroupChat);
|
||||
return {
|
||||
conversationId: firstRouteParam(rawParams.conversationId) ?? null,
|
||||
@@ -70,6 +81,10 @@ export const useChatScreen = () => {
|
||||
groupName: firstRouteParam(rawParams.groupName),
|
||||
};
|
||||
}, [
|
||||
props?.embeddedConversationId,
|
||||
props?.embeddedIsGroupChat,
|
||||
props?.embeddedGroupId,
|
||||
props?.embeddedGroupName,
|
||||
rawParams.conversationId,
|
||||
rawParams.userId,
|
||||
rawParams.isGroupChat,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,13 @@ import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Image } from 'expo-image';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { Text } from '../../components/common';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||
@@ -47,11 +50,48 @@ export const DataStorageScreen: React.FC = () => {
|
||||
|
||||
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null);
|
||||
const [asyncStorageSize, setAsyncStorageSize] = useState(0);
|
||||
const [expoImageCacheSize, setExpoImageCacheSize] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
|
||||
|
||||
const loadExpoImageCacheSize = useCallback(async (): Promise<number> => {
|
||||
if (Platform.OS === 'web') return 0;
|
||||
try {
|
||||
const cacheDir = Paths.cache;
|
||||
const sdImageCacheDir = new Directory(cacheDir, 'defaultDiskCache');
|
||||
const expoImageCacheDir = new Directory(cacheDir, 'expo-image');
|
||||
|
||||
const scanDirectorySize = async (dir: Directory): Promise<number> => {
|
||||
let size = 0;
|
||||
try {
|
||||
if (!(await dir.exists)) return 0;
|
||||
const items = dir.list();
|
||||
for (const item of items) {
|
||||
try {
|
||||
if (item instanceof File) {
|
||||
const info = await item.info();
|
||||
if (info.exists && info.size) {
|
||||
size += info.size;
|
||||
}
|
||||
} else if (item instanceof Directory) {
|
||||
size += await scanDirectorySize(item);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return size;
|
||||
};
|
||||
|
||||
const totalSize = await scanDirectorySize(sdImageCacheDir) + await scanDirectorySize(expoImageCacheDir);
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.warn('读取 expo-image 缓存大小失败:', error);
|
||||
return 0;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -59,6 +99,9 @@ export const DataStorageScreen: React.FC = () => {
|
||||
const stats = mediaCacheManager.getCacheStats();
|
||||
setCacheStats(stats);
|
||||
|
||||
const imageCacheSize = await loadExpoImageCacheSize();
|
||||
setExpoImageCacheSize(imageCacheSize);
|
||||
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
if (keys.length > 0) {
|
||||
const items = await AsyncStorage.multiGet(keys);
|
||||
@@ -77,7 +120,7 @@ export const DataStorageScreen: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadExpoImageCacheSize]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
@@ -86,7 +129,7 @@ export const DataStorageScreen: React.FC = () => {
|
||||
const handleClearCache = () => {
|
||||
Alert.alert(
|
||||
'清除缓存',
|
||||
'确定要清除所有媒体缓存吗?此操作不可撤销。',
|
||||
'确定要清除所有缓存吗?此操作不可撤销。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
@@ -96,6 +139,8 @@ export const DataStorageScreen: React.FC = () => {
|
||||
try {
|
||||
setClearing(true);
|
||||
await mediaCacheManager.clearAll();
|
||||
await Image.clearDiskCache();
|
||||
await Image.clearMemoryCache();
|
||||
await loadStats();
|
||||
Alert.alert('完成', '缓存已清除');
|
||||
} catch (error) {
|
||||
@@ -139,28 +184,27 @@ export const DataStorageScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const totalSize = (cacheStats?.totalSize ?? 0) + asyncStorageSize;
|
||||
const totalSize = (cacheStats?.totalSize ?? 0) + asyncStorageSize + expoImageCacheSize;
|
||||
|
||||
const storageItems: StorageStatItem[] = useMemo(() => {
|
||||
if (!cacheStats) return [];
|
||||
return [
|
||||
{
|
||||
icon: 'image-outline',
|
||||
label: '图片缓存',
|
||||
value: cacheStats.imageCount > 0 ? `${cacheStats.imageCount} 个文件` : '暂无',
|
||||
count: cacheStats.imageCount,
|
||||
value: expoImageCacheSize > 0 ? `${formatBytes(expoImageCacheSize)}` : cacheStats && cacheStats.imageCount > 0 ? `${cacheStats.imageCount} 个文件` : '暂无',
|
||||
count: expoImageCacheSize > 0 ? undefined : cacheStats?.imageCount,
|
||||
},
|
||||
{
|
||||
icon: 'video-outline',
|
||||
label: '视频缓存',
|
||||
value: cacheStats.videoCount > 0 ? `${cacheStats.videoCount} 个文件` : '暂无',
|
||||
count: cacheStats.videoCount,
|
||||
value: cacheStats && cacheStats.videoCount > 0 ? `${cacheStats.videoCount} 个文件` : '暂无',
|
||||
count: cacheStats?.videoCount,
|
||||
},
|
||||
{
|
||||
icon: 'music-note-outline',
|
||||
label: '音频缓存',
|
||||
value: cacheStats.audioCount > 0 ? `${cacheStats.audioCount} 个文件` : '暂无',
|
||||
count: cacheStats.audioCount,
|
||||
value: cacheStats && cacheStats.audioCount > 0 ? `${cacheStats.audioCount} 个文件` : '暂无',
|
||||
count: cacheStats?.audioCount,
|
||||
},
|
||||
{
|
||||
icon: 'file-document-outline',
|
||||
@@ -168,7 +212,7 @@ export const DataStorageScreen: React.FC = () => {
|
||||
value: asyncStorageSize > 0 ? formatBytes(asyncStorageSize) : '暂无',
|
||||
},
|
||||
];
|
||||
}, [cacheStats, asyncStorageSize]);
|
||||
}, [cacheStats, asyncStorageSize, expoImageCacheSize]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
|
||||
@@ -11,19 +11,32 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
// 主题配置 - 扩展包含更多颜色
|
||||
export const CHAT_THEMES = [
|
||||
{
|
||||
id: 'default',
|
||||
id: 'white',
|
||||
name: '纯白',
|
||||
primary: '#FF6B35',
|
||||
secondary: '#F0F2F5',
|
||||
bubble: '#E8E8E8',
|
||||
icon: '🤍',
|
||||
card: '#FFFFFF',
|
||||
inputBg: '#FFFFFF',
|
||||
panelBg: '#F5F5F5',
|
||||
headerBg: '#FFFFFF',
|
||||
textPrimary: '#1A1A1A',
|
||||
textSecondary: '#666666',
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
name: '默认绿',
|
||||
primary: '#4CAF50',
|
||||
secondary: '#E8F5E9',
|
||||
bubble: '#DCF8C6',
|
||||
icon: '🏠',
|
||||
// 扩展颜色
|
||||
card: '#FFFFFF', // 卡片背景
|
||||
inputBg: '#FFFFFF', // 输入框背景
|
||||
panelBg: '#F5F5F5', // 面板背景
|
||||
headerBg: '#FFFFFF', // 头部背景
|
||||
textPrimary: '#1A1A1A', // 主要文字
|
||||
textSecondary: '#666666', // 次要文字
|
||||
card: '#FFFFFF',
|
||||
inputBg: '#FFFFFF',
|
||||
panelBg: '#F5F5F5',
|
||||
headerBg: '#FFFFFF',
|
||||
textPrimary: '#1A1A1A',
|
||||
textSecondary: '#666666',
|
||||
},
|
||||
{
|
||||
id: 'yellow',
|
||||
|
||||
Reference in New Issue
Block a user