- 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
1131 lines
36 KiB
TypeScript
1131 lines
36 KiB
TypeScript
/**
|
||
* 消息列表页 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,
|
||
},
|
||
});
|
||
}
|