2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 消息列表页 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,
|
2026-03-21 12:08:45 +08:00
|
|
|
|
BackHandler,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
Platform,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { useRouter } from 'expo-router';
|
|
|
|
|
|
import { useIsFocused } from '@react-navigation/native';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import {
|
|
|
|
|
|
spacing,
|
|
|
|
|
|
fontSizes,
|
|
|
|
|
|
shadows,
|
|
|
|
|
|
borderRadius,
|
|
|
|
|
|
useAppColors,
|
|
|
|
|
|
type AppColors,
|
|
|
|
|
|
} from '../../theme';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto';
|
2026-03-23 13:40:48 +08:00
|
|
|
|
import { authService } from '../../services';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { useUserStore, useAuthStore } from '../../stores';
|
2026-03-23 13:40:48 +08:00
|
|
|
|
// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步)
|
|
|
|
|
|
import {
|
|
|
|
|
|
messageManager,
|
|
|
|
|
|
useMessageListRefresh,
|
|
|
|
|
|
useCreateConversation,
|
|
|
|
|
|
useUnreadCount,
|
|
|
|
|
|
useMarkAsRead,
|
|
|
|
|
|
useMessageManagerConversations,
|
|
|
|
|
|
} from '../../stores';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import * as hrefs from '../../navigation/hrefs';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
|
|
|
|
|
import { EmbeddedChat } from './components/EmbeddedChat';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import { ConversationListRow } from './components/ConversationListRow';
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 导入 NotificationsScreen 用于在内部显示
|
|
|
|
|
|
import { NotificationsScreen } from './NotificationsScreen';
|
2026-03-20 19:28:42 +08:00
|
|
|
|
// 导入扫码组件
|
|
|
|
|
|
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 系统通知会话特殊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 = () => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = useMemo(() => createMessageListStyles(colors), [colors]);
|
2026-03-24 14:21:31 +08:00
|
|
|
|
const router = useRouter();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const currentUserId = useAuthStore(state => state.currentUser?.id);
|
|
|
|
|
|
const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount);
|
|
|
|
|
|
|
|
|
|
|
|
// 响应式布局
|
|
|
|
|
|
const { isDesktop, isTablet, width } = useResponsive();
|
|
|
|
|
|
const isWideScreen = useBreakpointGTE('lg');
|
|
|
|
|
|
|
2026-03-23 13:40:48 +08:00
|
|
|
|
// 会话列表:MessageManager 统一游标拉取与合并,与已读/角标同源
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const {
|
2026-03-23 13:40:48 +08:00
|
|
|
|
conversations: conversationList,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
isLoading,
|
2026-03-20 23:00:27 +08:00
|
|
|
|
hasMore,
|
|
|
|
|
|
loadMore,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
refresh,
|
2026-03-23 13:40:48 +08:00
|
|
|
|
} = useMessageManagerConversations();
|
2026-03-20 23:00:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用 MessageManager 获取未读数和系统通知数
|
|
|
|
|
|
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
|
|
|
|
|
|
const { markAllAsRead, isMarking } = useMarkAsRead(null);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 【新架构】使用MessageManager的hook创建会话
|
|
|
|
|
|
const { createConversation } = useCreateConversation();
|
|
|
|
|
|
|
|
|
|
|
|
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
2026-03-20 23:00:27 +08:00
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 上拉加载更多
|
|
|
|
|
|
const onEndReached = useCallback(async () => {
|
|
|
|
|
|
if (isLoading || loadingMore || !hasMore) return;
|
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
await loadMore();
|
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
|
}, [isLoading, loadingMore, hasMore, loadMore]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 搜索相关状态
|
|
|
|
|
|
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);
|
2026-03-20 19:28:42 +08:00
|
|
|
|
const [scannerVisible, setScannerVisible] = useState(false);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 动画值
|
|
|
|
|
|
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 遮挡导致最后几项无法完整滑出
|
2026-03-21 03:22:28 +08:00
|
|
|
|
// TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const listBottomInset = useMemo(() => {
|
|
|
|
|
|
if (isWideScreen) return spacing.lg;
|
2026-03-21 03:22:28 +08:00
|
|
|
|
const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距
|
|
|
|
|
|
return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md;
|
|
|
|
|
|
}, [isWideScreen, insets.bottom]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 【新架构】页面获得焦点时初始化MessageManager
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isFocused) {
|
|
|
|
|
|
messageManager.initialize();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isFocused]);
|
|
|
|
|
|
|
|
|
|
|
|
// 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
useMessageListRefresh();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 同步未读数到userStore(用于TabBar角标显示)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setMessageUnreadCount(totalUnreadCount + systemUnreadCount);
|
|
|
|
|
|
}, [totalUnreadCount, systemUnreadCount, setMessageUnreadCount]);
|
|
|
|
|
|
|
|
|
|
|
|
// 屏幕失去焦点时重置搜索状态
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!isFocused) {
|
|
|
|
|
|
setIsSearchMode(false);
|
|
|
|
|
|
setSearchText('');
|
|
|
|
|
|
setSearchResults([]);
|
|
|
|
|
|
setActiveSearchTab('chat');
|
|
|
|
|
|
setActionMenuVisible(false);
|
|
|
|
|
|
Keyboard.dismiss();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isFocused]);
|
|
|
|
|
|
|
2026-03-21 12:08:45 +08:00
|
|
|
|
// 处理 Android 物理返回键 - 当显示通知页面时,返回键关闭通知页面
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
|
|
|
|
if (showNotifications) {
|
|
|
|
|
|
setShowNotifications(false);
|
|
|
|
|
|
return true; // 阻止默认行为
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return () => backHandler.remove();
|
|
|
|
|
|
}, [showNotifications]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 下拉刷新
|
|
|
|
|
|
const onRefresh = useCallback(async () => {
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
await refresh();
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
}, [refresh]);
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
// 格式化时间(稳定引用,配合会话行 memo)
|
|
|
|
|
|
const formatTime = useCallback((dateString: string): string => {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
}
|
2026-03-24 05:52:24 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 跳转到聊天页
|
|
|
|
|
|
const handleConversationPress = (conversation: ConversationResponse, index: number) => {
|
|
|
|
|
|
Animated.sequence([
|
|
|
|
|
|
Animated.timing(scaleAnims[index], {
|
|
|
|
|
|
toValue: 0.98,
|
|
|
|
|
|
duration: 100,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}),
|
|
|
|
|
|
Animated.timing(scaleAnims[index], {
|
|
|
|
|
|
toValue: 1,
|
|
|
|
|
|
duration: 100,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}),
|
|
|
|
|
|
]).start(() => {
|
|
|
|
|
|
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 系统通知 - 根据屏幕宽度决定如何显示
|
2026-03-09 21:29:03 +08:00
|
|
|
|
if (isWideScreen) {
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
|
|
|
|
|
|
setSelectedConversation(systemMessageConversation);
|
|
|
|
|
|
setShowNotifications(true);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} else {
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 窄屏下也使用内部状态显示通知页面
|
|
|
|
|
|
setShowNotifications(true);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else if (isWideScreen) {
|
|
|
|
|
|
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 同时关闭系统通知页面
|
|
|
|
|
|
setShowNotifications(false);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setSelectedConversation(conversation);
|
|
|
|
|
|
} else if (conversation.type === 'group' && conversation.group) {
|
|
|
|
|
|
// 群聊 - 窄屏下导航
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(
|
|
|
|
|
|
hrefs.hrefChat({
|
|
|
|
|
|
conversationId: String(conversation.id),
|
|
|
|
|
|
groupId: String(conversation.group.id),
|
|
|
|
|
|
groupName: conversation.group.name,
|
|
|
|
|
|
isGroupChat: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 私聊 - 窄屏下导航
|
|
|
|
|
|
const currentUserId = useAuthStore.getState().currentUser?.id;
|
|
|
|
|
|
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(
|
|
|
|
|
|
hrefs.hrefChat({
|
|
|
|
|
|
conversationId: String(conversation.id),
|
|
|
|
|
|
userId: otherUser ? String(otherUser.id) : undefined,
|
|
|
|
|
|
isGroupChat: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
const handleConversationPressRef = useRef(handleConversationPress);
|
|
|
|
|
|
handleConversationPressRef.current = handleConversationPress;
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 创建群聊
|
|
|
|
|
|
const handleCreateGroup = () => {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefGroupCreate());
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 主动加群
|
|
|
|
|
|
const handleJoinGroup = () => {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(hrefs.hrefGroupJoin());
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenActionMenu = () => {
|
|
|
|
|
|
setActionMenuVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
setActionMenuVisible(false);
|
|
|
|
|
|
if (action === 'join') {
|
|
|
|
|
|
handleJoinGroup();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action === 'create') {
|
|
|
|
|
|
handleCreateGroup();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-20 19:28:42 +08:00
|
|
|
|
if (action === 'scanQRCode') {
|
|
|
|
|
|
setScannerVisible(true);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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[] = [];
|
|
|
|
|
|
|
2026-03-23 03:58:26 +08:00
|
|
|
|
for (const conv of conversationList) {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-23 03:58:26 +08:00
|
|
|
|
}, [conversationList, activeSearchTab]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 搜索文本变化时触发搜索
|
|
|
|
|
|
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(),
|
2026-03-23 03:58:26 +08:00
|
|
|
|
...conversationList,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
const listConvRef = useRef(new Map<string, ConversationResponse>());
|
|
|
|
|
|
listConvRef.current = new Map(listData.map((c) => [String(c.id), c]));
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
const stableConversationPress = useCallback((conversationId: string, index: number) => {
|
|
|
|
|
|
const c = listConvRef.current.get(conversationId);
|
|
|
|
|
|
if (c) {
|
|
|
|
|
|
handleConversationPressRef.current(c, index);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
2026-03-24 05:52:24 +08:00
|
|
|
|
}, []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
// 总未读数
|
|
|
|
|
|
const totalUnread = totalUnreadCount + systemUnreadCount;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
// 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘)
|
|
|
|
|
|
const renderConversation = useCallback(
|
|
|
|
|
|
({ item, index }: { item: ConversationResponse; index: number }) => {
|
|
|
|
|
|
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]
|
|
|
|
|
|
);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 渲染空状态
|
|
|
|
|
|
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>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.chat.iconSoft} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</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) {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.push(
|
|
|
|
|
|
hrefs.hrefChat({
|
|
|
|
|
|
conversationId: String(conversation.id),
|
|
|
|
|
|
userId: String(user.id),
|
|
|
|
|
|
isGroupChat: false,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} 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 && (
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.chat.iconSoft} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</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}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<AppBackButton onPress={handleCloseSearch} iconColor={colors.text.secondary} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<TextInput
|
|
|
|
|
|
style={styles.searchInput}
|
|
|
|
|
|
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
2026-03-25 05:16:54 +08:00
|
|
|
|
placeholderTextColor={colors.text.hint}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
value={searchText}
|
|
|
|
|
|
onChangeText={setSearchText}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
returnKeyType="search"
|
2026-03-16 17:47:10 +08:00
|
|
|
|
// 确保光标可见
|
|
|
|
|
|
cursorColor={colors.primary.main}
|
|
|
|
|
|
selectionColor={`${colors.primary.main}40`}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
/>
|
|
|
|
|
|
{searchText.length > 0 && (
|
|
|
|
|
|
<TouchableOpacity onPress={() => setSearchText('')}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="close-circle" size={18} color={colors.text.hint} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</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}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="plus" size={24} color={colors.text.secondary} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
|
|
|
|
|
|
onPress={handleSearch}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.searchBox}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="magnify" size={18} color={colors.text.hint} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text style={styles.searchPlaceholder}>搜索</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
2026-03-23 03:58:26 +08:00
|
|
|
|
{isLoading && conversationList.length === 0 ? (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<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}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
onEndReached={onEndReached}
|
|
|
|
|
|
onEndReachedThreshold={0.5}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
2026-03-20 23:00:27 +08:00
|
|
|
|
ListFooterComponent={
|
|
|
|
|
|
loadingMore ? (
|
|
|
|
|
|
<View style={styles.loadingMoreContainer}>
|
|
|
|
|
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
|
|
|
|
|
<Text style={styles.loadingMoreText}>加载中...</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
) : null
|
|
|
|
|
|
}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderActionMenu = () => (
|
|
|
|
|
|
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
|
|
|
|
|
|
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
|
|
|
|
|
|
<View style={styles.actionMenu}>
|
2026-03-20 19:28:42 +08:00
|
|
|
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="qrcode-scan" size={21} color={colors.text.primary} />
|
2026-03-20 19:28:42 +08:00
|
|
|
|
<Text style={styles.actionMenuText}>扫码登录</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="account-plus-outline" size={21} color={colors.text.primary} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text style={styles.actionMenuText}>加入群聊</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('create')}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="account-multiple-plus" size={21} color={colors.text.primary} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text style={styles.actionMenuText}>创建群聊</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('readAll')} disabled={isMarking}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="message-check-outline" size={21} color={colors.text.primary} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text style={styles.actionMenuText}>{isMarking ? '处理中...' : '全部已读'}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</Pressable>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 桌面端双栏布局
|
|
|
|
|
|
if (isWideScreen && !isSearchMode) {
|
|
|
|
|
|
return (
|
2026-03-18 10:58:41 +08:00
|
|
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<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 ? (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 显示选中会话的聊天内容
|
|
|
|
|
|
<EmbeddedChat
|
|
|
|
|
|
conversation={selectedConversation}
|
|
|
|
|
|
onBack={() => setSelectedConversation(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// 默认占位符
|
|
|
|
|
|
<View style={styles.chatPlaceholder}>
|
|
|
|
|
|
<View style={styles.chatPlaceholderContent}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="message-text-outline" size={64} color={colors.chat.iconMuted} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text style={styles.chatPlaceholderText}>选择一个会话开始聊天</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
{renderActionMenu()}
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-18 10:58:41 +08:00
|
|
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
2026-03-16 17:47:10 +08:00
|
|
|
|
{showNotifications ? (
|
|
|
|
|
|
// 显示系统通知页面,传入 onBack 回调
|
|
|
|
|
|
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
|
|
|
|
|
) : isSearchMode ? (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
renderSearchMode()
|
2026-03-16 17:47:10 +08:00
|
|
|
|
) : isWideScreen ? (
|
|
|
|
|
|
<ResponsiveContainer maxWidth={800}>
|
|
|
|
|
|
{renderConversationList()}
|
|
|
|
|
|
</ResponsiveContainer>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
) : (
|
2026-03-16 17:47:10 +08:00
|
|
|
|
renderConversationList()
|
2026-03-09 21:29:03 +08:00
|
|
|
|
)}
|
|
|
|
|
|
{renderActionMenu()}
|
2026-03-20 19:28:42 +08:00
|
|
|
|
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
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,
|
|
|
|
|
|
...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: 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',
|
|
|
|
|
|
},
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchBox: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
backgroundColor: colors.chat.surfaceInput,
|
|
|
|
|
|
borderRadius: 10,
|
|
|
|
|
|
paddingHorizontal: spacing.sm,
|
|
|
|
|
|
paddingVertical: 10,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchPlaceholder: {
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
marginLeft: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
listContent: {
|
|
|
|
|
|
flexGrow: 1,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchInputContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
backgroundColor: colors.chat.surfaceInput,
|
|
|
|
|
|
borderRadius: 10,
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
marginHorizontal: spacing.md,
|
|
|
|
|
|
marginTop: spacing.md,
|
|
|
|
|
|
height: 40,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchInput: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
marginLeft: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchTabs: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
gap: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchTab: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
paddingVertical: spacing.sm,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
backgroundColor: colors.chat.surfaceInput,
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchTabActive: {
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchTabText: {
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
},
|
|
|
|
|
|
searchTabTextActive: {
|
|
|
|
|
|
color: colors.primary.contrast,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchResultsContent: {
|
|
|
|
|
|
flexGrow: 1,
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchResultItem: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
paddingVertical: spacing.md,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
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: 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|