1374 lines
40 KiB
TypeScript
1374 lines
40 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';
|
||
// 导入 NotificationsScreen 用于在内部显示
|
||
import { NotificationsScreen } from './NotificationsScreen';
|
||
|
||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||
|
||
// 系统通知会话特殊ID
|
||
const SYSTEM_MESSAGE_CHANNEL_ID = '-1';
|
||
|
||
// 搜索结果类型
|
||
type SearchResultType = 'conversation' | 'user';
|
||
|
||
interface SearchResultItem {
|
||
type: SearchResultType;
|
||
conversation?: ConversationResponse;
|
||
user?: UserDTO;
|
||
matchedMessages?: MessageResponse[];
|
||
}
|
||
|
||
// 动画值
|
||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||
const MAX_CONVERSATION_NAME_LENGTH = 10;
|
||
|
||
const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => {
|
||
if (!name) return '';
|
||
if (name.length <= maxLength) return name;
|
||
return `${name.slice(0, maxLength)}...`;
|
||
};
|
||
|
||
/**
|
||
* 异步消息预览组件
|
||
*/
|
||
const AsyncMessagePreview: React.FC<{
|
||
segments?: MessageSegment[];
|
||
status?: string;
|
||
isGroupChat?: boolean;
|
||
senderName?: string;
|
||
}> = ({ segments, status, isGroupChat, senderName }) => {
|
||
const [displayText, setDisplayText] = useState<string>('');
|
||
const isMountedRef = useRef(true);
|
||
|
||
useEffect(() => {
|
||
isMountedRef.current = true;
|
||
|
||
const loadPreview = async () => {
|
||
if (status === 'recalled') {
|
||
if (isMountedRef.current) {
|
||
setDisplayText('消息已撤回');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const initialText = extractTextFromSegments(segments);
|
||
if (isMountedRef.current) {
|
||
setDisplayText(initialText);
|
||
}
|
||
|
||
const hasAtWithoutNickname = segments?.some(
|
||
s => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname
|
||
);
|
||
|
||
if (hasAtWithoutNickname) {
|
||
try {
|
||
const asyncText = await extractTextFromSegmentsAsync(
|
||
segments,
|
||
getUserCache,
|
||
authService.getUserById.bind(authService)
|
||
);
|
||
if (isMountedRef.current && asyncText !== initialText) {
|
||
setDisplayText(asyncText);
|
||
}
|
||
} catch (error) {
|
||
console.warn('[AsyncMessagePreview] 获取用户名失败:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
loadPreview();
|
||
|
||
return () => {
|
||
isMountedRef.current = false;
|
||
};
|
||
}, [segments, status]);
|
||
|
||
if (!displayText) return null;
|
||
|
||
if (isGroupChat && senderName) {
|
||
return <>{senderName}: {displayText}</>;
|
||
}
|
||
|
||
return <>{displayText}</>;
|
||
};
|
||
|
||
/**
|
||
* MessageListScreen - 纯渲染组件
|
||
* 所有数据通过useMessageList hook获取
|
||
* 支持响应式双栏布局
|
||
*/
|
||
export const MessageListScreen: React.FC = () => {
|
||
const navigation = useNavigation<NavigationProp & MessageNavProp>();
|
||
const isFocused = useIsFocused();
|
||
const insets = useSafeAreaInsets();
|
||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
||
const { isMobile } = useResponsive();
|
||
let tabBarHeight = 0;
|
||
try {
|
||
tabBarHeight = useBottomTabBarHeight();
|
||
} catch (e) {
|
||
// 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可
|
||
tabBarHeight = 0;
|
||
}
|
||
const currentUserId = useAuthStore(state => state.currentUser?.id);
|
||
const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount);
|
||
|
||
// 响应式布局
|
||
const { isDesktop, isTablet, width } = useResponsive();
|
||
const isWideScreen = useBreakpointGTE('lg');
|
||
|
||
// 【新架构】使用MessageManager的hook获取数据
|
||
const {
|
||
conversations,
|
||
isLoading,
|
||
refresh,
|
||
totalUnreadCount,
|
||
systemUnreadCount,
|
||
markAllAsRead,
|
||
isMarking,
|
||
} = useMessageList();
|
||
|
||
// 【新架构】使用MessageManager的hook创建会话
|
||
const { createConversation } = useCreateConversation();
|
||
|
||
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
|
||
// 搜索相关状态
|
||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||
const [searchText, setSearchText] = useState('');
|
||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||
|
||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||
const [showNotifications, setShowNotifications] = useState(false);
|
||
|
||
// 系统消息会话对象(用于选中状态)
|
||
const systemMessageConversation: ConversationResponse = useMemo(() => ({
|
||
id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||
type: 'private',
|
||
last_seq: 0,
|
||
last_message: {
|
||
id: '0',
|
||
conversation_id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||
sender_id: 'system',
|
||
seq: 0,
|
||
segments: [{ type: 'text', data: { text: '系统通知' } }],
|
||
status: 'normal',
|
||
created_at: new Date().toISOString(),
|
||
},
|
||
last_message_at: new Date().toISOString(),
|
||
unread_count: systemUnreadCount,
|
||
participants: [],
|
||
created_at: new Date().toISOString(),
|
||
updated_at: new Date().toISOString(),
|
||
}), [systemUnreadCount]);
|
||
|
||
// 动画值
|
||
const [scaleAnims] = useState(() =>
|
||
Array.from({ length: 20 }, () => new Animated.Value(1))
|
||
);
|
||
|
||
// 【桌面端双栏布局】选中会话的状态
|
||
const [selectedConversation, setSelectedConversation] = useState<ConversationResponse | null>(null);
|
||
|
||
// 计算侧边栏宽度(桌面端)
|
||
const sidebarWidth = useMemo(() => {
|
||
if (width >= 1440) return 380;
|
||
if (width >= 1280) return 340;
|
||
if (width >= 1024) return 320;
|
||
return 280;
|
||
}, [width]);
|
||
|
||
// 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出
|
||
const listBottomInset = useMemo(() => {
|
||
if (isWideScreen) return spacing.lg;
|
||
return tabBarHeight + insets.bottom + spacing.md;
|
||
}, [isWideScreen, tabBarHeight, insets.bottom]);
|
||
|
||
// 【新架构】页面获得焦点时初始化MessageManager
|
||
useEffect(() => {
|
||
if (isFocused) {
|
||
messageManager.initialize();
|
||
}
|
||
}, [isFocused]);
|
||
|
||
// 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数
|
||
useMessageListRefresh(isFocused);
|
||
|
||
// 同步未读数到userStore(用于TabBar角标显示)
|
||
useEffect(() => {
|
||
setMessageUnreadCount(totalUnreadCount + systemUnreadCount);
|
||
}, [totalUnreadCount, systemUnreadCount, setMessageUnreadCount]);
|
||
|
||
// 屏幕失去焦点时重置搜索状态
|
||
useEffect(() => {
|
||
if (!isFocused) {
|
||
setIsSearchMode(false);
|
||
setSearchText('');
|
||
setSearchResults([]);
|
||
setActiveSearchTab('chat');
|
||
setActionMenuVisible(false);
|
||
Keyboard.dismiss();
|
||
}
|
||
}, [isFocused]);
|
||
|
||
// 下拉刷新
|
||
const onRefresh = useCallback(async () => {
|
||
setRefreshing(true);
|
||
await refresh();
|
||
setRefreshing(false);
|
||
}, [refresh]);
|
||
|
||
// 格式化时间
|
||
const formatTime = (dateString: string): string => {
|
||
try {
|
||
const date = new Date(dateString);
|
||
const now = new Date();
|
||
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||
|
||
if (diffInHours < 24 && date.getDate() === now.getDate()) {
|
||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
const yesterday = new Date(now);
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
if (date.getDate() === yesterday.getDate()) {
|
||
return '昨天';
|
||
}
|
||
|
||
if (diffInHours < 24 * 7) {
|
||
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||
return days[date.getDay()];
|
||
}
|
||
|
||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||
} catch {
|
||
return '';
|
||
}
|
||
};
|
||
|
||
// 跳转到聊天页
|
||
const handleConversationPress = (conversation: ConversationResponse, index: number) => {
|
||
Animated.sequence([
|
||
Animated.timing(scaleAnims[index], {
|
||
toValue: 0.98,
|
||
duration: 100,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(scaleAnims[index], {
|
||
toValue: 1,
|
||
duration: 100,
|
||
useNativeDriver: true,
|
||
}),
|
||
]).start(() => {
|
||
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
||
// 系统通知 - 根据屏幕宽度决定如何显示
|
||
if (isWideScreen) {
|
||
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
|
||
setSelectedConversation(systemMessageConversation);
|
||
setShowNotifications(true);
|
||
} else {
|
||
// 窄屏下也使用内部状态显示通知页面
|
||
setShowNotifications(true);
|
||
}
|
||
} else if (isWideScreen) {
|
||
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
|
||
// 同时关闭系统通知页面
|
||
setShowNotifications(false);
|
||
setSelectedConversation(conversation);
|
||
} else if (conversation.type === 'group' && conversation.group) {
|
||
// 群聊 - 窄屏下导航
|
||
(navigation as any).navigate('Chat', {
|
||
conversationId: String(conversation.id),
|
||
groupId: String(conversation.group.id),
|
||
groupName: conversation.group.name,
|
||
isGroupChat: true,
|
||
});
|
||
} else {
|
||
// 私聊 - 窄屏下导航
|
||
const currentUserId = useAuthStore.getState().currentUser?.id;
|
||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||
(navigation as any).navigate('Chat', {
|
||
conversationId: String(conversation.id),
|
||
userId: otherUser ? String(otherUser.id) : undefined,
|
||
userName: otherUser?.nickname || otherUser?.username,
|
||
isGroupChat: false,
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
// 创建群聊
|
||
const handleCreateGroup = () => {
|
||
(navigation as any).navigate('CreateGroup');
|
||
};
|
||
|
||
// 主动加群
|
||
const handleJoinGroup = () => {
|
||
(navigation as any).navigate('JoinGroup');
|
||
};
|
||
|
||
const handleOpenActionMenu = () => {
|
||
setActionMenuVisible(true);
|
||
};
|
||
|
||
const runMenuAction = (action: 'join' | 'create' | 'readAll') => {
|
||
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}
|
||
status={item.last_message?.status}
|
||
isGroupChat={isGroupChat}
|
||
senderName={getSenderName()}
|
||
/>
|
||
)}
|
||
</Text>
|
||
{item.unread_count > 0 && (
|
||
<View style={styles.unreadBadge}>
|
||
<Text style={styles.unreadBadgeText}>
|
||
{item.unread_count > 99 ? '99+' : item.unread_count}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{__DEV__ && (
|
||
<Text style={{fontSize: 10, color: '#999', marginLeft: 8}}>
|
||
[DEBUG: {item.unread_count || 0}]
|
||
</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
</AnimatedTouchable>
|
||
);
|
||
};
|
||
|
||
// 渲染空状态
|
||
const renderEmpty = () => (
|
||
<EmptyState
|
||
title="暂无消息"
|
||
description="开始聊天吧,与其他用户交流互动"
|
||
icon="message-text-outline"
|
||
/>
|
||
);
|
||
|
||
// 高亮搜索关键词
|
||
const highlightKeyword = (text: string, keyword: string): React.ReactNode => {
|
||
if (!text || !keyword) return text;
|
||
|
||
const lowerText = text.toLowerCase();
|
||
const lowerKeyword = keyword.toLowerCase();
|
||
const index = lowerText.indexOf(lowerKeyword);
|
||
|
||
if (index === -1) return text;
|
||
|
||
return (
|
||
<Text>
|
||
<Text>{text.substring(0, index)}</Text>
|
||
<Text style={styles.highlightText}>{text.substring(index, index + keyword.length)}</Text>
|
||
<Text>{text.substring(index + keyword.length)}</Text>
|
||
</Text>
|
||
);
|
||
};
|
||
|
||
// 渲染搜索结果项
|
||
const renderSearchResult = ({ item }: { item: SearchResultItem }) => {
|
||
if (item.type === 'conversation' && item.conversation) {
|
||
const conv = item.conversation;
|
||
const otherUser = conv.participants?.find(
|
||
p => String(p.id) !== String(currentUserId)
|
||
);
|
||
const matchedMessages = item.matchedMessages || [];
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
style={styles.searchResultItem}
|
||
onPress={() => handleConversationPress(conv, 0)}
|
||
>
|
||
<Avatar
|
||
source={otherUser?.avatar || null}
|
||
size={44}
|
||
name={otherUser?.nickname || '未知用户'}
|
||
/>
|
||
<View style={styles.searchResultContent}>
|
||
<View style={styles.searchResultHeader}>
|
||
<Text style={styles.searchResultName}>
|
||
{otherUser?.nickname || otherUser?.username || '未知用户'}
|
||
</Text>
|
||
{matchedMessages.length > 0 && (
|
||
<Text style={styles.searchResultTime}>
|
||
{matchedMessages.length} 条相关消息
|
||
</Text>
|
||
)}
|
||
</View>
|
||
<View style={styles.matchedMessagesContainer}>
|
||
{matchedMessages.map((msg, idx) => (
|
||
<View key={msg.id || idx} style={styles.matchedMessageItem}>
|
||
<Text style={styles.matchedMessageText} numberOfLines={2}>
|
||
{highlightKeyword(extractTextFromSegments(msg.segments), searchText)}
|
||
</Text>
|
||
<Text style={styles.matchedMessageTime}>
|
||
{formatTime(msg.created_at)}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" />
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
if (item.type === 'user' && item.user) {
|
||
const user = item.user;
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
style={styles.searchResultItem}
|
||
onPress={async () => {
|
||
try {
|
||
const conversation = await createConversation(String(user.id));
|
||
|
||
if (conversation) {
|
||
(navigation as any).navigate('Chat', {
|
||
conversationId: String(conversation.id),
|
||
userId: String(user.id),
|
||
userName: user.nickname || user.username,
|
||
isGroupChat: false,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('创建会话失败:', error);
|
||
}
|
||
}}
|
||
>
|
||
<Avatar
|
||
source={user.avatar}
|
||
size={44}
|
||
name={user.nickname}
|
||
/>
|
||
<View style={styles.searchResultContent}>
|
||
<Text style={styles.searchResultName}>
|
||
{highlightKeyword(user.nickname, searchText)}
|
||
</Text>
|
||
<Text style={styles.searchResultMessage} numberOfLines={1}>
|
||
{highlightKeyword(`@${user.username}`, searchText)}
|
||
{user.bio ? ` · ${highlightKeyword(user.bio, searchText)}` : ''}
|
||
</Text>
|
||
</View>
|
||
{user.is_following && (
|
||
<View style={styles.followingBadge}>
|
||
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
|
||
</View>
|
||
)}
|
||
{!user.is_following && (
|
||
<MaterialCommunityIcons name="chevron-right" size={20} color="#CCC" />
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// 渲染搜索空状态
|
||
const renderSearchEmpty = () => {
|
||
if (isSearching) {
|
||
return (
|
||
<View style={styles.loadingContainer}>
|
||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||
<Text style={styles.searchingText}>搜索中...</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
if (searchText.trim() && searchResults.length === 0) {
|
||
return (
|
||
<EmptyState
|
||
title={`未找到相关${activeSearchTab === 'chat' ? '聊天记录' : '用户'}`}
|
||
description="试试其他关键词吧"
|
||
icon={activeSearchTab === 'chat' ? 'message-search-outline' : 'account-search-outline'}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!searchText.trim()) {
|
||
return (
|
||
<EmptyState
|
||
title="搜索聊天记录或用户"
|
||
description="输入关键词开始搜索"
|
||
icon="magnify"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
// 渲染搜索模式UI
|
||
const renderSearchMode = () => (
|
||
<View style={styles.searchModeContainer}>
|
||
<View style={styles.searchInputContainer}>
|
||
<TouchableOpacity onPress={handleCloseSearch}>
|
||
<MaterialCommunityIcons name="arrow-left" size={22} color="#666" />
|
||
</TouchableOpacity>
|
||
<TextInput
|
||
style={styles.searchInput}
|
||
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
|
||
placeholderTextColor="#999"
|
||
value={searchText}
|
||
onChangeText={setSearchText}
|
||
autoFocus
|
||
returnKeyType="search"
|
||
// 确保光标可见
|
||
cursorColor={colors.primary.main}
|
||
selectionColor={`${colors.primary.main}40`}
|
||
/>
|
||
{searchText.length > 0 && (
|
||
<TouchableOpacity onPress={() => setSearchText('')}>
|
||
<MaterialCommunityIcons name="close-circle" size={18} color="#999" />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.searchTabs}>
|
||
<TouchableOpacity
|
||
style={[styles.searchTab, activeSearchTab === 'chat' && styles.searchTabActive]}
|
||
onPress={() => setActiveSearchTab('chat')}
|
||
>
|
||
<Text style={activeSearchTab === 'chat' ? styles.searchTabTextActive : styles.searchTabText}>
|
||
聊天记录
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[styles.searchTab, activeSearchTab === 'user' && styles.searchTabActive]}
|
||
onPress={() => setActiveSearchTab('user')}
|
||
>
|
||
<Text style={activeSearchTab === 'user' ? styles.searchTabTextActive : styles.searchTabText}>
|
||
用户
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<FlatList
|
||
data={searchResults}
|
||
renderItem={renderSearchResult}
|
||
keyExtractor={(item, index) => {
|
||
if (item.type === 'conversation') {
|
||
return `conv-${item.conversation?.id || index}`;
|
||
}
|
||
return `user-${item.user?.id || index}`;
|
||
}}
|
||
contentContainerStyle={[styles.searchResultsContent, { paddingBottom: listBottomInset }]}
|
||
showsVerticalScrollIndicator={false}
|
||
ListEmptyComponent={renderSearchEmpty}
|
||
/>
|
||
</View>
|
||
);
|
||
|
||
// 渲染会话列表内容
|
||
const renderConversationList = () => (
|
||
<>
|
||
<View style={[styles.header, ...(isWideScreen ? [styles.headerWide] : [])]}>
|
||
<View style={styles.headerLeft} />
|
||
|
||
<View style={styles.headerCenter}>
|
||
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>消息</Text>
|
||
{totalUnread > 0 && (
|
||
<View style={styles.totalBadge}>
|
||
<Text style={styles.totalBadgeText}>{totalUnread > 99 ? '99+' : totalUnread}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.headerRightContainer}>
|
||
<TouchableOpacity style={styles.headerRightBtn} onPress={handleOpenActionMenu}>
|
||
<MaterialCommunityIcons name="plus" size={24} color="#666" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.searchContainer, ...(isWideScreen ? [styles.searchContainerWide] : [])]}
|
||
onPress={handleSearch}
|
||
activeOpacity={0.7}
|
||
>
|
||
<View style={styles.searchBox}>
|
||
<MaterialCommunityIcons name="magnify" size={18} color="#999" />
|
||
<Text style={styles.searchPlaceholder}>搜索</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
{isLoading ? (
|
||
<View style={styles.loadingContainer}>
|
||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
data={listData}
|
||
renderItem={renderConversation}
|
||
keyExtractor={(item) => String(item.id)}
|
||
contentContainerStyle={[
|
||
styles.listContent,
|
||
...(isWideScreen ? [styles.listContentWide] : []),
|
||
{ paddingBottom: listBottomInset },
|
||
]}
|
||
showsVerticalScrollIndicator={false}
|
||
ListEmptyComponent={renderEmpty}
|
||
refreshControl={
|
||
<RefreshControl
|
||
refreshing={refreshing}
|
||
onRefresh={onRefresh}
|
||
colors={[colors.primary.main]}
|
||
tintColor={colors.primary.main}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const renderActionMenu = () => (
|
||
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
|
||
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
|
||
<View style={styles.actionMenu}>
|
||
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('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}>
|
||
{showNotifications ? (
|
||
// 显示系统通知页面
|
||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||
) : selectedConversation ? (
|
||
// 显示选中会话的聊天内容
|
||
<EmbeddedChat
|
||
conversation={selectedConversation}
|
||
onBack={() => setSelectedConversation(null)}
|
||
/>
|
||
) : (
|
||
// 默认占位符
|
||
<View style={styles.chatPlaceholder}>
|
||
<View style={styles.chatPlaceholderContent}>
|
||
<MaterialCommunityIcons name="message-text-outline" size={64} color="#DDD" />
|
||
<Text style={styles.chatPlaceholderText}>选择一个会话开始聊天</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
{renderActionMenu()}
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top']}>
|
||
{showNotifications ? (
|
||
// 显示系统通知页面,传入 onBack 回调
|
||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||
) : 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: colors.primary.light + '20',
|
||
borderColor: colors.primary.main,
|
||
borderWidth: 1,
|
||
},
|
||
avatarContainer: {
|
||
position: 'relative',
|
||
},
|
||
systemAvatar: {
|
||
width: 50,
|
||
height: 50,
|
||
borderRadius: 12,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
backgroundColor: colors.primary.main,
|
||
},
|
||
conversationContent: {
|
||
flex: 1,
|
||
marginLeft: spacing.md,
|
||
},
|
||
conversationHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 4,
|
||
},
|
||
nameRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
officialBadge: {
|
||
backgroundColor: colors.primary.main,
|
||
borderRadius: 4,
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
marginRight: spacing.xs,
|
||
},
|
||
officialBadgeText: {
|
||
color: '#FFF',
|
||
fontSize: 10,
|
||
fontWeight: '600',
|
||
},
|
||
userName: {
|
||
fontWeight: '600',
|
||
color: '#333',
|
||
fontSize: 16,
|
||
},
|
||
groupIcon: {
|
||
marginRight: 4,
|
||
},
|
||
memberCount: {
|
||
fontSize: 12,
|
||
color: '#999',
|
||
marginLeft: 2,
|
||
},
|
||
pinnedIcon: {
|
||
marginLeft: 6,
|
||
},
|
||
groupAvatar: {
|
||
width: 50,
|
||
height: 50,
|
||
},
|
||
groupAvatarPlaceholder: {
|
||
width: 50,
|
||
height: 50,
|
||
borderRadius: 12,
|
||
backgroundColor: colors.primary.main,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
timeText: {
|
||
color: '#999',
|
||
fontSize: 12,
|
||
},
|
||
messageRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
messageText: {
|
||
flex: 1,
|
||
color: '#888',
|
||
fontSize: 14,
|
||
},
|
||
unreadMessageText: {
|
||
color: '#333',
|
||
fontWeight: '500',
|
||
},
|
||
unreadBadge: {
|
||
minWidth: 20,
|
||
height: 20,
|
||
borderRadius: 10,
|
||
backgroundColor: colors.primary.main,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: spacing.sm,
|
||
paddingHorizontal: 6,
|
||
},
|
||
unreadBadgeText: {
|
||
color: '#FFF',
|
||
fontSize: 12,
|
||
fontWeight: '600',
|
||
},
|
||
loadingContainer: {
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.xl * 2,
|
||
},
|
||
searchModeContainer: {
|
||
flex: 1,
|
||
backgroundColor: '#FAFAFA',
|
||
},
|
||
searchInputContainer: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
backgroundColor: '#F0F0F0',
|
||
borderRadius: 10,
|
||
paddingHorizontal: spacing.md,
|
||
marginHorizontal: spacing.md,
|
||
marginTop: spacing.md,
|
||
height: 40,
|
||
},
|
||
searchInput: {
|
||
flex: 1,
|
||
fontSize: 15,
|
||
color: '#333',
|
||
marginLeft: spacing.md,
|
||
},
|
||
searchTabs: {
|
||
flexDirection: 'row',
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.sm,
|
||
gap: spacing.sm,
|
||
},
|
||
searchTab: {
|
||
flex: 1,
|
||
paddingVertical: spacing.sm,
|
||
alignItems: 'center',
|
||
backgroundColor: '#F0F0F0',
|
||
borderRadius: 8,
|
||
},
|
||
searchTabActive: {
|
||
backgroundColor: colors.primary.main,
|
||
},
|
||
searchTabText: {
|
||
fontSize: 14,
|
||
color: '#666',
|
||
fontWeight: '500',
|
||
},
|
||
searchTabTextActive: {
|
||
color: '#FFF',
|
||
},
|
||
searchResultsContent: {
|
||
flexGrow: 1,
|
||
paddingHorizontal: spacing.md,
|
||
},
|
||
searchResultItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.md,
|
||
backgroundColor: '#FFF',
|
||
borderRadius: 10,
|
||
marginBottom: spacing.sm,
|
||
paddingHorizontal: spacing.md,
|
||
},
|
||
searchResultContent: {
|
||
flex: 1,
|
||
marginLeft: spacing.md,
|
||
},
|
||
searchResultHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 2,
|
||
},
|
||
searchResultName: {
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
color: '#333',
|
||
},
|
||
searchResultTime: {
|
||
fontSize: 12,
|
||
color: '#999',
|
||
},
|
||
searchResultMessage: {
|
||
fontSize: 13,
|
||
color: '#888',
|
||
},
|
||
searchingText: {
|
||
marginTop: spacing.sm,
|
||
fontSize: 14,
|
||
color: '#999',
|
||
},
|
||
followingBadge: {
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: 12,
|
||
backgroundColor: colors.primary.light + '30',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
highlightText: {
|
||
color: colors.primary.main,
|
||
fontWeight: '700',
|
||
backgroundColor: colors.primary.light + '30',
|
||
},
|
||
matchedMessagesContainer: {
|
||
marginTop: spacing.sm,
|
||
},
|
||
matchedMessageItem: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
paddingVertical: spacing.xs,
|
||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||
borderBottomColor: '#F0F0F0',
|
||
},
|
||
matchedMessageText: {
|
||
flex: 1,
|
||
fontSize: 13,
|
||
color: '#555',
|
||
lineHeight: 18,
|
||
},
|
||
matchedMessageTime: {
|
||
fontSize: 11,
|
||
color: '#AAA',
|
||
marginLeft: spacing.sm,
|
||
minWidth: 45,
|
||
},
|
||
});
|