Files
frontend/src/screens/message/MessageListScreen.tsx

1386 lines
41 KiB
TypeScript
Raw Normal View History

/**
* 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';
2026-03-16 17:47:10 +08:00
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
// 导入扫码组件
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
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();
2026-03-16 17:47:10 +08:00
// 在大屏幕模式下不使用 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 [scannerVisible, setScannerVisible] = useState(false);
2026-03-16 17:47:10 +08:00
// 系统通知显示状态 - 用于在移动端显示通知页面
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) {
2026-03-16 17:47:10 +08:00
// 系统通知 - 根据屏幕宽度决定如何显示
if (isWideScreen) {
2026-03-16 17:47:10 +08:00
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
setSelectedConversation(systemMessageConversation);
setShowNotifications(true);
} else {
2026-03-16 17:47:10 +08:00
// 窄屏下也使用内部状态显示通知页面
setShowNotifications(true);
}
} else if (isWideScreen) {
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
2026-03-16 17:47:10 +08:00
// 同时关闭系统通知页面
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' | 'scanQRCode') => {
setActionMenuVisible(false);
if (action === 'join') {
handleJoinGroup();
return;
}
if (action === 'create') {
handleCreateGroup();
return;
}
if (action === 'scanQRCode') {
setScannerVisible(true);
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"
2026-03-16 17:47:10 +08:00
// 确保光标可见
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('scanQRCode')}>
<MaterialCommunityIcons name="qrcode-scan" size={21} color="#444" />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<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', 'bottom']}>
<View style={styles.desktopContainer}>
{/* 左侧会话列表 */}
<View style={[styles.sidebar, { width: sidebarWidth }]}>
{renderConversationList()}
</View>
{/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */}
<View style={styles.chatArea}>
2026-03-16 17:47:10 +08:00
{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', 'bottom']}>
2026-03-16 17:47:10 +08:00
{showNotifications ? (
// 显示系统通知页面,传入 onBack 回调
<NotificationsScreen onBack={() => setShowNotifications(false)} />
) : isSearchMode ? (
renderSearchMode()
2026-03-16 17:47:10 +08:00
) : isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
{renderConversationList()}
</ResponsiveContainer>
) : (
2026-03-16 17:47:10 +08:00
renderConversationList()
)}
{renderActionMenu()}
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
</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: {
2026-03-16 17:47:10 +08:00
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,
},
});