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