Files
frontend/src/screens/message/MessageListScreen.tsx
lafay 705b365536
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m11s
Frontend CI / ota (ios) (push) Successful in 3m31s
Frontend CI / build-and-push-web (push) Successful in 4m38s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
2026-06-21 17:17:15 +08:00

1131 lines
36 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
* 威友 - 消息列表
*
* 【重构后】使用MessageManager架构
* - 纯渲染组件,不直接管理数据
* - 通过useMessageList hook获取数据
* - 所有状态变更通过MessageManager统一处理
* - 支持响应式双栏布局(桌面端)
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
View,
StyleSheet,
TouchableOpacity,
Pressable,
Modal,
RefreshControl,
ActivityIndicator,
Animated,
Keyboard,
BackHandler,
Platform,
} from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useIsFocused } from "expo-router/react-navigation";
import { useBottomTabBarHeight } from "expo-router/js-tabs";
import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
spacing,
fontSizes,
shadows,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
import { formatTime as formatTimeUtil } from '../../utils/formatTime';
import { authService } from '../../services';
import { blurActiveElement } from '../../infrastructure/platform';
import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步)
import {
messageManager,
useMessageListRefresh,
useCreateConversation,
useUnreadCount,
useMarkAsRead,
useMessageManagerConversations,
useMessageStore,
} from '../../stores';
import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks';
import * as hrefs from '../../navigation/hrefs';
// 导入 ChatScreen 组件用于桌面端双栏布局
import { ChatScreen } from './ChatScreen';
import { ConversationListRow } from './components/ConversationListRow';
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
// 导入扫码组件
import { SearchBar, SearchHeader, TabBar } from '../../components/business';
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
// 系统通知会话特殊ID
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
// 搜索结果类型
type SearchResultType = 'conversation' | 'user';
interface SearchResultItem {
type: SearchResultType;
conversation?: ConversationResponse;
user?: UserDTO;
matchedMessages?: MessageResponse[];
}
/**
* MessageListScreen - 纯渲染组件
* 所有数据通过useMessageList hook获取
* 支持响应式双栏布局
*/
export const MessageListScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createMessageListStyles(colors), [colors]);
const router = useRouter();
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 统一游标拉取与合并,与已读/角标同源
const {
conversations: conversationList,
isLoading,
hasMore,
loadMore,
refresh,
} = useMessageManagerConversations();
// 使用 MessageManager 获取未读数和系统通知数
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
const lastSystemMessageAt = useMessageStore(state => state.lastSystemMessageAt);
const systemMessageTime = lastSystemMessageAt ?? '';
const { markAllAsRead, isMarking } = useMarkAsRead(null);
// 【新架构】使用MessageManager的hook创建会话
const { createConversation } = useCreateConversation();
// 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// 上拉加载更多
const onEndReached = useCallback(async () => {
if (isLoading || loadingMore || !hasMore) return;
setLoadingMore(true);
await loadMore();
setLoadingMore(false);
}, [isLoading, loadingMore, hasMore, loadMore]);
// 搜索相关状态
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);
useEffect(() => {
if (actionMenuVisible) {
blurActiveElement();
}
}, [actionMenuVisible]);
const [scannerVisible, setScannerVisible] = useState(false);
// 系统通知显示状态 - 用于在移动端显示通知页面
const [showNotifications, setShowNotifications] = useState(false);
// Stable callbacks to avoid inline arrows passed to child components
const stableCloseNotifications = useCallback(() => setShowNotifications(false), []);
const stableClearSelectedConversation = useCallback(() => setSelectedConversation(null), []);
const stableCloseScanner = useCallback(() => setScannerVisible(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: systemMessageTime,
},
last_message_at: systemMessageTime,
unread_count: systemUnreadCount,
participants: [],
created_at: systemMessageTime,
updated_at: systemMessageTime,
}), [systemUnreadCount, systemMessageTime]);
// 动画值
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 遮挡导致最后几项无法完整滑出
// TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88
const listBottomInset = useMemo(() => {
if (isWideScreen) return spacing.lg;
const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
}, [isWideScreen, insets.bottom]);
// 初始化MessageManager仅首次焦点时轻量刷新会话列表
useEffect(() => {
messageManager.initialize();
}, []);
useMessageListRefresh();
// 同步未读数到userStore用于TabBar角标显示
useEffect(() => {
setMessageUnreadCount(totalUnreadCount + systemUnreadCount);
}, [totalUnreadCount, systemUnreadCount, setMessageUnreadCount]);
// 屏幕失去焦点时重置搜索状态
useEffect(() => {
if (!isFocused) {
setIsSearchMode(false);
setSearchText('');
setSearchResults([]);
setActiveSearchTab('chat');
setActionMenuVisible(false);
Keyboard.dismiss();
}
}, [isFocused]);
// 处理 Android 物理返回键 - 当显示通知页面时,返回键关闭通知页面
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showNotifications) {
setShowNotifications(false);
return true; // 阻止默认行为
}
return false;
});
return () => backHandler.remove();
}, [showNotifications]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setRefreshing(true);
await refresh();
setRefreshing(false);
}, [refresh]);
// 格式化时间(稳定引用,配合会话行 memo
const formatTime = useCallback((dateString: string): string => {
return formatTimeUtil(dateString);
}, []);
// 跳转到聊天页
const handleConversationPress = (conversation: ConversationResponse, index: number) => {
Animated.sequence([
Animated.timing(scaleAnims[index], {
toValue: 0.98,
duration: 100,
useNativeDriver: Platform.OS !== 'web',
}),
Animated.timing(scaleAnims[index], {
toValue: 1,
duration: 100,
useNativeDriver: Platform.OS !== 'web',
}),
]).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) {
// 群聊 - 窄屏下导航
router.push(
hrefs.hrefChat({
conversationId: String(conversation.id),
groupId: String(conversation.group.id),
groupName: conversation.group.name,
groupAvatar: conversation.group.avatar,
isGroupChat: true,
})
);
} else {
// 私聊 - 窄屏下导航
const currentUserId = useAuthStore.getState().currentUser?.id;
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
router.push(
hrefs.hrefChat({
conversationId: String(conversation.id),
userId: otherUser ? String(otherUser.id) : undefined,
isGroupChat: false,
})
);
}
});
};
const handleConversationPressRef = useRef(handleConversationPress);
handleConversationPressRef.current = handleConversationPress;
// 创建群聊
const handleCreateGroup = () => {
router.push(hrefs.hrefGroupCreate());
};
// 主动加群
const handleJoinGroup = () => {
router.push(hrefs.hrefGroupJoin());
};
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 conversationList) {
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);
}
}, [conversationList, 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: systemMessageTime,
},
last_message_at: systemMessageTime,
unread_count: systemUnreadCount,
participants: [],
created_at: systemMessageTime,
updated_at: systemMessageTime,
};
};
// 合并列表数据:系统消息与普通会话一起按时间排序
const listData: ConversationResponse[] = [
createSystemMessageItem(),
...conversationList,
].sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.updated_at || 0).getTime();
const bTime = new Date(b.updated_at || 0).getTime();
if (Number.isNaN(aTime) || Number.isNaN(bTime)) return 0;
return bTime - aTime;
});
const listConvRef = useRef(new Map<string, ConversationResponse>());
listConvRef.current = new Map(listData.map((c) => [String(c.id), c]));
const stableConversationPress = useCallback((conversationId: string, index: number) => {
const c = listConvRef.current.get(conversationId);
if (c) {
handleConversationPressRef.current(c, index);
}
}, []);
// 总未读数
const totalUnread = totalUnreadCount + systemUnreadCount;
// 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘)
const renderConversation = useCallback<ListRenderItem<ConversationResponse>>(
({ item, index }) => {
const isSelected = selectedConversation?.id === item.id;
return (
<ConversationListRow
item={item}
index={index}
scale={scaleAnims[index] ?? 1}
isSelected={isSelected}
formatTime={formatTime}
systemChannelId={SYSTEM_MESSAGE_CHANNEL_ID}
onPress={() => stableConversationPress(String(item.id), index)}
/>
);
},
[selectedConversation?.id, scaleAnims, formatTime, stableConversationPress]
);
// 渲染空状态
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={colors.chat.iconSoft} />
</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) {
router.push(
hrefs.hrefChat({
conversationId: String(conversation.id),
userId: String(user.id),
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={colors.chat.iconSoft} />
)}
</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}>
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
<SearchHeader
value={searchText}
onChangeText={setSearchText}
onSubmit={() => performSearch(searchText)}
onCancel={handleCloseSearch}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
compact
/>
{/* Tab切换 - 与主页一致的下划线风格 */}
<View style={styles.searchTabContainer}>
<TabBar
tabs={['聊天记录', '用户']}
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
variant="home"
style={{
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
}}
/>
</View>
{/* 搜索结果 */}
<FlashList
data={searchResults}
renderItem={renderSearchResult}
keyExtractor={(item, index) => {
if (item.type === 'conversation') {
return `conv-${item.conversation?.id || index}`;
}
return `user-${item.user?.id || index}`;
}}
contentContainerStyle={{
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: listBottomInset,
}}
showsVerticalScrollIndicator={false}
scrollEnabled={true}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
drawDistance={250}
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={colors.text.secondary} />
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
onPress={handleSearch}
activeOpacity={0.85}
>
<View pointerEvents="none">
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={() => {}}
placeholder="搜索"
/>
</View>
</TouchableOpacity>
{isLoading && conversationList.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlashList
data={listData}
renderItem={renderConversation}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={{
backgroundColor: colors.background.default,
paddingHorizontal: spacing.xs,
paddingBottom: listBottomInset,
}}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
scrollEnabled={true}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
drawDistance={250}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
ListFooterComponent={
loadingMore ? (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text style={styles.loadingMoreText}>...</Text>
</View>
) : null
}
/>
)}
</>
);
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={colors.text.primary} />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
<MaterialCommunityIcons name="account-plus-outline" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('create')}>
<MaterialCommunityIcons name="account-multiple-plus" size={21} color={colors.text.primary} />
<Text style={styles.actionMenuText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('readAll')} disabled={isMarking}>
<MaterialCommunityIcons name="message-check-outline" size={21} color={colors.text.primary} />
<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}>
{showNotifications ? (
// 显示系统通知页面
(<NotificationsScreen onBack={stableCloseNotifications} />)
) : selectedConversation ? (
// 显示选中会话的聊天内容
(<ChatScreen
isEmbedded
embeddedConversationId={String(selectedConversation.id)}
embeddedIsGroupChat={selectedConversation.type === 'group'}
embeddedGroupId={selectedConversation.group ? String(selectedConversation.group.id) : undefined}
embeddedGroupName={selectedConversation.group?.name}
onEmbeddedBack={stableClearSelectedConversation}
/>)
) : (
// 默认占位符
(<View style={styles.chatPlaceholder}>
<View style={styles.chatPlaceholderContent}>
<MaterialCommunityIcons name="message-text-outline" size={64} color={colors.chat.iconMuted} />
<Text style={styles.chatPlaceholderText}></Text>
</View>
</View>)
)}
</View>
</View>
{renderActionMenu()}
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{showNotifications ? (
// 显示系统通知页面,传入 onBack 回调
(<NotificationsScreen onBack={stableCloseNotifications} />)
) : isSearchMode ? (
renderSearchMode()
) : isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
{renderConversationList()}
</ResponsiveContainer>
) : (
renderConversationList()
)}
{renderActionMenu()}
<QRCodeScanner visible={scannerVisible} onClose={stableCloseScanner} />
</SafeAreaView>
);
};
function createMessageListStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
// 桌面端双栏布局
desktopContainer: {
flex: 1,
flexDirection: 'row',
},
sidebar: {
backgroundColor: colors.background.default,
borderRightWidth: 1,
borderRightColor: colors.chat.border,
},
chatArea: {
flex: 1,
backgroundColor: colors.chat.surfaceMuted,
},
chatPlaceholder: {
flex: 1,
backgroundColor: colors.chat.surfaceMuted,
alignItems: 'center',
justifyContent: 'center',
},
chatPlaceholderContent: {
alignItems: 'center',
justifyContent: 'center',
},
chatPlaceholderText: {
marginTop: spacing.md,
fontSize: 16,
color: colors.text.hint,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.background.default,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider + '60',
},
headerWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.lg,
},
headerTitleWide: {
fontSize: 22,
},
searchContainerWide: {
paddingHorizontal: spacing.lg,
},
listContentWide: {
paddingHorizontal: spacing.xs,
},
headerLeft: {
width: 44,
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerTitle: {
fontSize: 19,
fontWeight: '700',
color: colors.text.primary,
},
totalBadge: {
minWidth: 18,
height: 18,
borderRadius: 9,
backgroundColor: colors.error.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 5,
},
totalBadgeText: {
color: colors.primary.contrast,
fontSize: 11,
fontWeight: '600',
lineHeight: 18,
textAlignVertical: 'center',
includeFontPadding: false,
},
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: colors.background.paper,
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: colors.text.primary,
},
searchContainer: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
backgroundColor: colors.background.default,
},
listContent: {
flexGrow: 1,
backgroundColor: colors.background.default,
paddingHorizontal: spacing.xs,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.xl * 2,
},
loadingMoreContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
},
loadingMoreText: {
marginLeft: spacing.sm,
fontSize: 14,
color: colors.text.hint,
},
searchModeContainer: {
flex: 1,
backgroundColor: colors.background.default,
},
searchTabContainer: {
backgroundColor: colors.background.default,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
},
searchResultsContent: {
flexGrow: 1,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
},
searchResultItem: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
backgroundColor: colors.background.default,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: `${colors.divider}50`,
},
searchResultContent: {
flex: 1,
marginLeft: spacing.md,
},
searchResultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
},
searchResultName: {
fontSize: 15,
fontWeight: '600',
color: colors.text.primary,
},
searchResultTime: {
fontSize: 12,
color: colors.text.hint,
},
searchResultMessage: {
fontSize: 13,
color: colors.text.secondary,
},
searchingText: {
marginTop: spacing.sm,
fontSize: 14,
color: colors.text.hint,
},
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: colors.divider,
},
matchedMessageText: {
flex: 1,
fontSize: 13,
color: colors.text.secondary,
lineHeight: 18,
},
matchedMessageTime: {
fontSize: 11,
color: colors.text.hint,
marginLeft: spacing.sm,
minWidth: 45,
},
});
}