Files
frontend/src/screens/message/MessageListScreen.tsx
2026-03-16 17:47:10 +08:00

1374 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 消息列表页 MessageListScreen
* 胡萝卜BBS - 消息列表
*
* 【重构后】使用MessageManager架构
* - 纯渲染组件,不直接管理数据
* - 通过useMessageList hook获取数据
* - 所有状态变更通过MessageManager统一处理
* - 支持响应式双栏布局(桌面端)
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
Pressable,
Modal,
RefreshControl,
ActivityIndicator,
Animated,
TextInput,
Keyboard,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useIsFocused } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto';
import { authService } from '../../services';
import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks
import { useMessageList, messageManager, useMessageListRefresh, useCreateConversation } from '../../stores';
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
import { getUserCache } from '../../services/database';
// 导入 EmbeddedChat 组件用于桌面端双栏布局
import { EmbeddedChat } from './components/EmbeddedChat';
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
// 系统通知会话特殊ID
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
// 搜索结果类型
type SearchResultType = 'conversation' | 'user';
interface SearchResultItem {
type: SearchResultType;
conversation?: ConversationResponse;
user?: UserDTO;
matchedMessages?: MessageResponse[];
}
// 动画值
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
const MAX_CONVERSATION_NAME_LENGTH = 10;
const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => {
if (!name) return '';
if (name.length <= maxLength) return name;
return `${name.slice(0, maxLength)}...`;
};
/**
* 异步消息预览组件
*/
const AsyncMessagePreview: React.FC<{
segments?: MessageSegment[];
status?: string;
isGroupChat?: boolean;
senderName?: string;
}> = ({ segments, status, isGroupChat, senderName }) => {
const [displayText, setDisplayText] = useState<string>('');
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
const loadPreview = async () => {
if (status === 'recalled') {
if (isMountedRef.current) {
setDisplayText('消息已撤回');
}
return;
}
const initialText = extractTextFromSegments(segments);
if (isMountedRef.current) {
setDisplayText(initialText);
}
const hasAtWithoutNickname = segments?.some(
s => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname
);
if (hasAtWithoutNickname) {
try {
const asyncText = await extractTextFromSegmentsAsync(
segments,
getUserCache,
authService.getUserById.bind(authService)
);
if (isMountedRef.current && asyncText !== initialText) {
setDisplayText(asyncText);
}
} catch (error) {
console.warn('[AsyncMessagePreview] 获取用户名失败:', error);
}
}
};
loadPreview();
return () => {
isMountedRef.current = false;
};
}, [segments, status]);
if (!displayText) return null;
if (isGroupChat && senderName) {
return <>{senderName}: {displayText}</>;
}
return <>{displayText}</>;
};
/**
* MessageListScreen - 纯渲染组件
* 所有数据通过useMessageList hook获取
* 支持响应式双栏布局
*/
export const MessageListScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp & MessageNavProp>();
const isFocused = useIsFocused();
const insets = useSafeAreaInsets();
// 在大屏幕模式下不使用 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);
// 响应式布局
const { isDesktop, isTablet, width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 【新架构】使用MessageManager的hook获取数据
const {
conversations,
isLoading,
refresh,
totalUnreadCount,
systemUnreadCount,
markAllAsRead,
isMarking,
} = useMessageList();
// 【新架构】使用MessageManager的hook创建会话
const { createConversation } = useCreateConversation();
// 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false);
// 搜索相关状态
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchText, setSearchText] = useState('');
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [isSearching, setIsSearching] = useState(false);
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))
);
// 【桌面端双栏布局】选中会话的状态
const [selectedConversation, setSelectedConversation] = useState<ConversationResponse | null>(null);
// 计算侧边栏宽度(桌面端)
const sidebarWidth = useMemo(() => {
if (width >= 1440) return 380;
if (width >= 1280) return 340;
if (width >= 1024) return 320;
return 280;
}, [width]);
// 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出
const listBottomInset = useMemo(() => {
if (isWideScreen) return spacing.lg;
return tabBarHeight + insets.bottom + spacing.md;
}, [isWideScreen, tabBarHeight, insets.bottom]);
// 【新架构】页面获得焦点时初始化MessageManager
useEffect(() => {
if (isFocused) {
messageManager.initialize();
}
}, [isFocused]);
// 【新架构】使用focus刷新hook从ChatScreen返回时自动刷新未读数
useMessageListRefresh(isFocused);
// 同步未读数到userStore用于TabBar角标显示
useEffect(() => {
setMessageUnreadCount(totalUnreadCount + systemUnreadCount);
}, [totalUnreadCount, systemUnreadCount, setMessageUnreadCount]);
// 屏幕失去焦点时重置搜索状态
useEffect(() => {
if (!isFocused) {
setIsSearchMode(false);
setSearchText('');
setSearchResults([]);
setActiveSearchTab('chat');
setActionMenuVisible(false);
Keyboard.dismiss();
}
}, [isFocused]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setRefreshing(true);
await refresh();
setRefreshing(false);
}, [refresh]);
// 格式化时间
const formatTime = (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()) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.getDate() === yesterday.getDate()) {
return '昨天';
}
if (diffInHours < 24 * 7) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return days[date.getDay()];
}
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch {
return '';
}
};
// 跳转到聊天页
const handleConversationPress = (conversation: ConversationResponse, index: number) => {
Animated.sequence([
Animated.timing(scaleAnims[index], {
toValue: 0.98,
duration: 100,
useNativeDriver: true,
}),
Animated.timing(scaleAnims[index], {
toValue: 1,
duration: 100,
useNativeDriver: true,
}),
]).start(() => {
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
// 系统通知 - 根据屏幕宽度决定如何显示
if (isWideScreen) {
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
setSelectedConversation(systemMessageConversation);
setShowNotifications(true);
} else {
// 窄屏下也使用内部状态显示通知页面
setShowNotifications(true);
}
} else if (isWideScreen) {
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
// 同时关闭系统通知页面
setShowNotifications(false);
setSelectedConversation(conversation);
} else if (conversation.type === 'group' && conversation.group) {
// 群聊 - 窄屏下导航
(navigation as any).navigate('Chat', {
conversationId: String(conversation.id),
groupId: String(conversation.group.id),
groupName: conversation.group.name,
isGroupChat: true,
});
} else {
// 私聊 - 窄屏下导航
const currentUserId = useAuthStore.getState().currentUser?.id;
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
(navigation as any).navigate('Chat', {
conversationId: String(conversation.id),
userId: otherUser ? String(otherUser.id) : undefined,
userName: otherUser?.nickname || otherUser?.username,
isGroupChat: false,
});
}
});
};
// 创建群聊
const handleCreateGroup = () => {
(navigation as any).navigate('CreateGroup');
};
// 主动加群
const handleJoinGroup = () => {
(navigation as any).navigate('JoinGroup');
};
const handleOpenActionMenu = () => {
setActionMenuVisible(true);
};
const runMenuAction = (action: 'join' | 'create' | 'readAll') => {
setActionMenuVisible(false);
if (action === 'join') {
handleJoinGroup();
return;
}
if (action === 'create') {
handleCreateGroup();
return;
}
handleMarkAllRead();
};
// 标记所有消息为已读
const handleMarkAllRead = async () => {
try {
await markAllAsRead();
setMessageUnreadCount(0);
} catch (error) {
console.error('标记已读失败:', error);
}
};
// 进入搜索模式
const handleSearch = () => {
setIsSearchMode(true);
setSearchText('');
setSearchResults([]);
};
// 退出搜索模式
const handleCloseSearch = () => {
setIsSearchMode(false);
setSearchText('');
setSearchResults([]);
Keyboard.dismiss();
};
// 执行搜索
const performSearch = useCallback(async (keyword: string) => {
if (!keyword.trim()) {
setSearchResults([]);
return;
}
const trimmedKeyword = keyword.trim().toLowerCase();
setIsSearching(true);
try {
if (activeSearchTab === 'chat') {
const results: SearchResultItem[] = [];
for (const conv of conversations) {
await messageManager.fetchMessages(String(conv.id));
const messages = messageManager.getMessages(String(conv.id));
const matchedMessages = messages.filter(msg => {
const text = extractTextFromSegments(msg.segments);
return text.toLowerCase().includes(trimmedKeyword);
});
if (matchedMessages.length > 0) {
results.push({
type: 'conversation',
conversation: conv,
matchedMessages: matchedMessages,
});
}
}
setSearchResults(results);
} else {
const usersData = await authService.searchUsers(trimmedKeyword, 1, 20);
const results: SearchResultItem[] = (usersData.list || []).map(user => ({
type: 'user',
user,
}));
setSearchResults(results);
}
} catch (error) {
console.error('搜索失败:', error);
} finally {
setIsSearching(false);
}
}, [conversations, activeSearchTab]);
// 搜索文本变化时触发搜索
useEffect(() => {
const timer = setTimeout(() => {
if (searchText.trim()) {
performSearch(searchText);
} else {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(timer);
}, [searchText, performSearch]);
// 切换搜索Tab时重新搜索
useEffect(() => {
if (searchText.trim()) {
performSearch(searchText);
}
}, [activeSearchTab, searchText, performSearch]);
// 创建系统通知会话项
const createSystemMessageItem = (): ConversationResponse => {
const noticeContent = systemUnreadCount > 0 ? `您有 ${systemUnreadCount} 条未读通知` : '暂无新通知';
return {
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: noticeContent } }],
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(),
};
};
// 合并列表数据
const listData: ConversationResponse[] = [
createSystemMessageItem(),
...conversations,
];
// 总未读数
const totalUnread = totalUnreadCount + systemUnreadCount;
// 渲染会话项
const renderConversation = ({ item, index }: { item: ConversationResponse; index: number }) => {
const isSystemChannel = item.id === SYSTEM_MESSAGE_CHANNEL_ID;
const isGroupChat = !!(item.type === 'group' && item.group);
// 获取显示名称和头像
let displayNameRaw: string;
let displayName: string;
let displayAvatar: string | null = null;
if (isSystemChannel) {
displayNameRaw = '系统通知';
} else if (isGroupChat && item.group) {
displayNameRaw = item.group.name || '群聊';
displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null;
} else {
displayNameRaw = item.participants?.[0]?.nickname || '未知用户';
displayAvatar = item.participants?.[0]?.avatar || null;
}
displayName = truncateDisplayName(displayNameRaw);
const getSenderName = (): string | undefined => {
if (isGroupChat && item.last_message?.sender) {
return item.last_message.sender.nickname || item.last_message.sender.username;
}
return undefined;
};
// 是否选中(用于桌面端双栏布局高亮)
const isSelected = selectedConversation?.id === item.id;
return (
<AnimatedTouchable
style={[
styles.conversationItem,
isSelected && styles.conversationItemSelected,
{ transform: [{ scale: scaleAnims[index] || 1 }] }
]}
onPress={() => handleConversationPress(item, index)}
activeOpacity={0.7}
>
<View style={styles.avatarContainer}>
{isSystemChannel ? (
<View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={24} color="#FFF" />
</View>
) : isGroupChat ? (
<View style={styles.groupAvatar}>
{displayAvatar ? (
<Avatar source={displayAvatar} size={50} name={displayName} />
) : (
<View style={styles.groupAvatarPlaceholder}>
<MaterialCommunityIcons name="account-group" size={28} color="#FFF" />
</View>
)}
</View>
) : (
<Avatar source={displayAvatar} size={50} name={displayName} />
)}
</View>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.nameRow}>
{isSystemChannel && (
<View style={styles.officialBadge}>
<Text style={styles.officialBadgeText}></Text>
</View>
)}
{isGroupChat && (
<MaterialCommunityIcons
name="account-group"
size={16}
color={colors.primary.main}
style={styles.groupIcon}
/>
)}
<Text style={styles.userName}>{displayName}</Text>
{item.is_pinned && !isSystemChannel && (
<MaterialCommunityIcons
name="pin"
size={14}
color={colors.warning.main}
style={styles.pinnedIcon}
/>
)}
{isGroupChat && item.member_count !== undefined && (
<Text style={styles.memberCount}>({item.member_count})</Text>
)}
</View>
<Text style={styles.timeText}>
{formatTime(item.last_message_at || item.updated_at)}
</Text>
</View>
<View style={styles.messageRow}>
<Text
style={item.unread_count > 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText}
numberOfLines={1}
>
{!item.last_message ? (
'暂无消息'
) : (
<AsyncMessagePreview
segments={item.last_message?.segments}
status={item.last_message?.status}
isGroupChat={isGroupChat}
senderName={getSenderName()}
/>
)}
</Text>
{item.unread_count > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>
{item.unread_count > 99 ? '99+' : item.unread_count}
</Text>
</View>
)}
{__DEV__ && (
<Text style={{fontSize: 10, color: '#999', marginLeft: 8}}>
[DEBUG: {item.unread_count || 0}]
</Text>
)}
</View>
</View>
</AnimatedTouchable>
);
};
// 渲染空状态
const renderEmpty = () => (
<EmptyState
title="暂无消息"
description="开始聊天吧,与其他用户交流互动"
icon="message-text-outline"
/>
);
// 高亮搜索关键词
const highlightKeyword = (text: string, keyword: string): React.ReactNode => {
if (!text || !keyword) return text;
const lowerText = text.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
const index = lowerText.indexOf(lowerKeyword);
if (index === -1) return text;
return (
<Text>
<Text>{text.substring(0, index)}</Text>
<Text style={styles.highlightText}>{text.substring(index, index + keyword.length)}</Text>
<Text>{text.substring(index + keyword.length)}</Text>
</Text>
);
};
// 渲染搜索结果项
const renderSearchResult = ({ item }: { item: SearchResultItem }) => {
if (item.type === 'conversation' && item.conversation) {
const conv = item.conversation;
const otherUser = conv.participants?.find(
p => String(p.id) !== String(currentUserId)
);
const matchedMessages = item.matchedMessages || [];
return (
<TouchableOpacity
style={styles.searchResultItem}
onPress={() => handleConversationPress(conv, 0)}
>
<Avatar
source={otherUser?.avatar || null}
size={44}
name={otherUser?.nickname || '未知用户'}
/>
<View style={styles.searchResultContent}>
<View style={styles.searchResultHeader}>
<Text style={styles.searchResultName}>
{otherUser?.nickname || otherUser?.username || '未知用户'}
</Text>
{matchedMessages.length > 0 && (
<Text style={styles.searchResultTime}>
{matchedMessages.length}
</Text>
)}
</View>
<View style={styles.matchedMessagesContainer}>
{matchedMessages.map((msg, idx) => (
<View key={msg.id || idx} style={styles.matchedMessageItem}>
<Text style={styles.matchedMessageText} numberOfLines={2}>
{highlightKeyword(extractTextFromSegments(msg.segments), searchText)}
</Text>
<Text style={styles.matchedMessageTime}>
{formatTime(msg.created_at)}
</Text>
</View>
))}
</View>
</View>
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" />
</TouchableOpacity>
);
}
if (item.type === 'user' && item.user) {
const user = item.user;
return (
<TouchableOpacity
style={styles.searchResultItem}
onPress={async () => {
try {
const conversation = await createConversation(String(user.id));
if (conversation) {
(navigation as any).navigate('Chat', {
conversationId: String(conversation.id),
userId: String(user.id),
userName: user.nickname || user.username,
isGroupChat: false,
});
}
} catch (error) {
console.error('创建会话失败:', error);
}
}}
>
<Avatar
source={user.avatar}
size={44}
name={user.nickname}
/>
<View style={styles.searchResultContent}>
<Text style={styles.searchResultName}>
{highlightKeyword(user.nickname, searchText)}
</Text>
<Text style={styles.searchResultMessage} numberOfLines={1}>
{highlightKeyword(`@${user.username}`, searchText)}
{user.bio ? ` · ${highlightKeyword(user.bio, searchText)}` : ''}
</Text>
</View>
{user.is_following && (
<View style={styles.followingBadge}>
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
</View>
)}
{!user.is_following && (
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" />
)}
</TouchableOpacity>
);
}
return null;
};
// 渲染搜索空状态
const renderSearchEmpty = () => {
if (isSearching) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text style={styles.searchingText}>...</Text>
</View>
);
}
if (searchText.trim() && searchResults.length === 0) {
return (
<EmptyState
title={`未找到相关${activeSearchTab === 'chat' ? '聊天记录' : '用户'}`}
description="试试其他关键词吧"
icon={activeSearchTab === 'chat' ? 'message-search-outline' : 'account-search-outline'}
/>
);
}
if (!searchText.trim()) {
return (
<EmptyState
title="搜索聊天记录或用户"
description="输入关键词开始搜索"
icon="magnify"
/>
);
}
return null;
};
// 渲染搜索模式UI
const renderSearchMode = () => (
<View style={styles.searchModeContainer}>
<View style={styles.searchInputContainer}>
<TouchableOpacity onPress={handleCloseSearch}>
<MaterialCommunityIcons name="arrow-left" size={22} color="#666" />
</TouchableOpacity>
<TextInput
style={styles.searchInput}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
placeholderTextColor="#999"
value={searchText}
onChangeText={setSearchText}
autoFocus
returnKeyType="search"
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
{searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText('')}>
<MaterialCommunityIcons name="close-circle" size={18} color="#999" />
</TouchableOpacity>
)}
</View>
<View style={styles.searchTabs}>
<TouchableOpacity
style={[styles.searchTab, activeSearchTab === 'chat' && styles.searchTabActive]}
onPress={() => setActiveSearchTab('chat')}
>
<Text style={activeSearchTab === 'chat' ? styles.searchTabTextActive : styles.searchTabText}>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.searchTab, activeSearchTab === 'user' && styles.searchTabActive]}
onPress={() => setActiveSearchTab('user')}
>
<Text style={activeSearchTab === 'user' ? styles.searchTabTextActive : styles.searchTabText}>
</Text>
</TouchableOpacity>
</View>
<FlatList
data={searchResults}
renderItem={renderSearchResult}
keyExtractor={(item, index) => {
if (item.type === 'conversation') {
return `conv-${item.conversation?.id || index}`;
}
return `user-${item.user?.id || index}`;
}}
contentContainerStyle={[styles.searchResultsContent, { paddingBottom: listBottomInset }]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderSearchEmpty}
/>
</View>
);
// 渲染会话列表内容
const renderConversationList = () => (
<>
<View style={[styles.header, ...(isWideScreen ? [styles.headerWide] : [])]}>
<View style={styles.headerLeft} />
<View style={styles.headerCenter}>
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}></Text>
{totalUnread > 0 && (
<View style={styles.totalBadge}>
<Text style={styles.totalBadgeText}>{totalUnread > 99 ? '99+' : totalUnread}</Text>
</View>
)}
</View>
<View style={styles.headerRightContainer}>
<TouchableOpacity style={styles.headerRightBtn} onPress={handleOpenActionMenu}>
<MaterialCommunityIcons name="plus" size={24} color="#666" />
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
onPress={handleSearch}
activeOpacity={0.7}
>
<View style={styles.searchBox}>
<MaterialCommunityIcons name="magnify" size={18} color="#999" />
<Text style={styles.searchPlaceholder}></Text>
</View>
</TouchableOpacity>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
data={listData}
renderItem={renderConversation}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={[
styles.listContent,
...(isWideScreen ? [styles.listContentWide] : []),
{ paddingBottom: listBottomInset },
]}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
);
const renderActionMenu = () => (
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
<View style={styles.actionMenu}>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('create')}>
<MaterialCommunityIcons name="account-multiple-plus" size={21} color="#444" />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('readAll')} disabled={isMarking}>
<MaterialCommunityIcons name="message-check-outline" size={21} color="#444" />
<Text style={styles.actionMenuText}>{isMarking ? '处理中...' : '全部已读'}</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
);
// 桌面端双栏布局
if (isWideScreen && !isSearchMode) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.desktopContainer}>
{/* 左侧会话列表 */}
<View style={[styles.sidebar, { width: sidebarWidth }]}>
{renderConversationList()}
</View>
{/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */}
<View style={styles.chatArea}>
{showNotifications ? (
// 显示系统通知页面
<NotificationsScreen onBack={() => setShowNotifications(false)} />
) : selectedConversation ? (
// 显示选中会话的聊天内容
<EmbeddedChat
conversation={selectedConversation}
onBack={() => setSelectedConversation(null)}
/>
) : (
// 默认占位符
<View style={styles.chatPlaceholder}>
<View style={styles.chatPlaceholderContent}>
<MaterialCommunityIcons name="message-text-outline" size={64} color="#DDD" />
<Text style={styles.chatPlaceholderText}></Text>
</View>
</View>
)}
</View>
</View>
{renderActionMenu()}
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{showNotifications ? (
// 显示系统通知页面,传入 onBack 回调
<NotificationsScreen onBack={() => setShowNotifications(false)} />
) : isSearchMode ? (
renderSearchMode()
) : isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
{renderConversationList()}
</ResponsiveContainer>
) : (
renderConversationList()
)}
{renderActionMenu()}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#FAFAFA',
},
// 桌面端双栏布局
desktopContainer: {
flex: 1,
flexDirection: 'row',
},
sidebar: {
backgroundColor: '#FAFAFA',
borderRightWidth: 1,
borderRightColor: '#E8E8E8',
},
chatArea: {
flex: 1,
backgroundColor: '#F5F7FA',
},
chatPlaceholder: {
flex: 1,
backgroundColor: '#F5F7FA',
alignItems: 'center',
justifyContent: 'center',
},
chatPlaceholderContent: {
alignItems: 'center',
justifyContent: 'center',
},
chatPlaceholderText: {
marginTop: spacing.md,
fontSize: 16,
color: '#999',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: '#FAFAFA',
...shadows.sm,
},
headerWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
},
headerTitleWide: {
fontSize: 22,
},
searchContainerWide: {
paddingHorizontal: spacing.lg,
},
listContentWide: {
paddingHorizontal: spacing.lg,
},
headerLeft: {
width: 44,
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerTitle: {
fontSize: 19,
fontWeight: '700',
color: '#333',
},
totalBadge: {
minWidth: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#FF4757',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 5,
},
totalBadgeText: {
color: '#FFF',
fontSize: 11,
fontWeight: '600',
},
headerRight: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
headerRightContainer: {
flexDirection: 'row',
alignItems: 'center',
},
headerRightBtn: {
width: 36,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
actionMenuBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
},
actionMenu: {
position: 'absolute',
top: 88,
right: spacing.md,
width: 188,
backgroundColor: '#FFF',
borderRadius: 12,
paddingVertical: spacing.sm,
...shadows.md,
},
actionMenuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
},
actionMenuText: {
marginLeft: spacing.sm,
fontSize: 16,
color: '#333',
},
searchContainer: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
backgroundColor: '#FAFAFA',
},
searchBox: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F0F0F0',
borderRadius: 10,
paddingHorizontal: spacing.sm,
paddingVertical: 10,
},
searchPlaceholder: {
fontSize: 14,
color: '#999',
marginLeft: spacing.xs,
},
listContent: {
flexGrow: 1,
backgroundColor: '#FAFAFA',
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 14,
backgroundColor: '#FFF',
marginHorizontal: spacing.md,
marginTop: spacing.sm,
borderRadius: 12,
...shadows.sm,
},
conversationItemSelected: {
backgroundColor: colors.primary.light + '20',
borderColor: colors.primary.main,
borderWidth: 1,
},
avatarContainer: {
position: 'relative',
},
systemAvatar: {
width: 50,
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primary.main,
},
conversationContent: {
flex: 1,
marginLeft: spacing.md,
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
},
officialBadge: {
backgroundColor: colors.primary.main,
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
marginRight: spacing.xs,
},
officialBadgeText: {
color: '#FFF',
fontSize: 10,
fontWeight: '600',
},
userName: {
fontWeight: '600',
color: '#333',
fontSize: 16,
},
groupIcon: {
marginRight: 4,
},
memberCount: {
fontSize: 12,
color: '#999',
marginLeft: 2,
},
pinnedIcon: {
marginLeft: 6,
},
groupAvatar: {
width: 50,
height: 50,
},
groupAvatarPlaceholder: {
width: 50,
height: 50,
borderRadius: 12,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
timeText: {
color: '#999',
fontSize: 12,
},
messageRow: {
flexDirection: 'row',
alignItems: 'center',
},
messageText: {
flex: 1,
color: '#888',
fontSize: 14,
},
unreadMessageText: {
color: '#333',
fontWeight: '500',
},
unreadBadge: {
minWidth: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
paddingHorizontal: 6,
},
unreadBadgeText: {
color: '#FFF',
fontSize: 12,
fontWeight: '600',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.xl * 2,
},
searchModeContainer: {
flex: 1,
backgroundColor: '#FAFAFA',
},
searchInputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F0F0F0',
borderRadius: 10,
paddingHorizontal: spacing.md,
marginHorizontal: spacing.md,
marginTop: spacing.md,
height: 40,
},
searchInput: {
flex: 1,
fontSize: 15,
color: '#333',
marginLeft: spacing.md,
},
searchTabs: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
searchTab: {
flex: 1,
paddingVertical: spacing.sm,
alignItems: 'center',
backgroundColor: '#F0F0F0',
borderRadius: 8,
},
searchTabActive: {
backgroundColor: colors.primary.main,
},
searchTabText: {
fontSize: 14,
color: '#666',
fontWeight: '500',
},
searchTabTextActive: {
color: '#FFF',
},
searchResultsContent: {
flexGrow: 1,
paddingHorizontal: spacing.md,
},
searchResultItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
backgroundColor: '#FFF',
borderRadius: 10,
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
searchResultContent: {
flex: 1,
marginLeft: spacing.md,
},
searchResultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
},
searchResultName: {
fontSize: 15,
fontWeight: '600',
color: '#333',
},
searchResultTime: {
fontSize: 12,
color: '#999',
},
searchResultMessage: {
fontSize: 13,
color: '#888',
},
searchingText: {
marginTop: spacing.sm,
fontSize: 14,
color: '#999',
},
followingBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary.light + '30',
alignItems: 'center',
justifyContent: 'center',
},
highlightText: {
color: colors.primary.main,
fontWeight: '700',
backgroundColor: colors.primary.light + '30',
},
matchedMessagesContainer: {
marginTop: spacing.sm,
},
matchedMessageItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: spacing.xs,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#F0F0F0',
},
matchedMessageText: {
flex: 1,
fontSize: 13,
color: '#555',
lineHeight: 18,
},
matchedMessageTime: {
fontSize: 11,
color: '#AAA',
marginLeft: spacing.sm,
minWidth: 45,
},
});