/** * 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。 * onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。 */ import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, shadows, borderRadius, useAppColors, type AppColors } from '../../../theme'; import { ConversationResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment, } from '../../../types/dto'; import { authService } from '../../../services'; import { userCacheRepository, messageRepository } from '@/database'; import { Avatar, Text } from '../../../components/common'; 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; conversationId?: string; }> = ({ segments, status, isGroupChat, senderName, conversationId }) => { const [displayText, setDisplayText] = useState(''); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; const loadPreview = async () => { if (status === 'recalled') { if (isMountedRef.current) { setDisplayText('消息已撤回'); } return; } let effectiveSegments = segments; // 当 segments 为空时,从本地 SQLite 回退查询 if ((!effectiveSegments || effectiveSegments.length === 0) && conversationId) { try { const lastMsg = await messageRepository.getLastMessageByConversation(conversationId); if (lastMsg?.segments) { effectiveSegments = Array.isArray(lastMsg.segments) ? lastMsg.segments : JSON.parse(lastMsg.segments); } } catch {} } const initialText = extractTextFromSegments(effectiveSegments); if (isMountedRef.current) { setDisplayText(initialText); } const hasAtWithoutNickname = effectiveSegments?.some( (s) => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname ); if (hasAtWithoutNickname) { try { const asyncText = await extractTextFromSegmentsAsync( effectiveSegments, userCacheRepository.get.bind(userCacheRepository), authService.getUserById.bind(authService) ); if (isMountedRef.current && asyncText !== initialText) { setDisplayText(asyncText); } } catch (error) { console.warn('[AsyncMessagePreview] 获取用户名失败:', error); } } }; loadPreview(); return () => { isMountedRef.current = false; }; }, [segments, status, conversationId]); if (!displayText) return null; if (isGroupChat && senderName) { return ( <> {senderName}: {displayText} ); } return <>{displayText}; }; export interface ConversationListRowProps { item: ConversationResponse; index: number; scale: Animated.Value | number; isSelected: boolean; onPress: () => void; formatTime: (dateString: string) => string; systemChannelId: string; } function conversationVisualSignature(item: ConversationResponse): string { const lm = item.last_message; const lmSender = lm?.sender; const lmPart = lm ? [ lm.id, String(lm.seq), lm.status, String(lm.segments?.length ?? 0), extractTextFromSegments(lm.segments), lmSender?.nickname ?? '', lmSender?.username ?? '', ].join('|') : ''; const g = item.group; const gPart = g ? [String(g.id), g.name, g.avatar ?? ''].join('|') : ''; const p0 = item.participants?.[0]; const pPart = p0 ? [p0.id, p0.nickname ?? '', p0.avatar ?? ''].join('|') : ''; return [ String(item.id), item.type, item.is_pinned ? '1' : '0', String(item.unread_count), item.last_message_at, item.updated_at, String(item.last_seq), String(item.member_count ?? ''), lmPart, gPart, pPart, ].join('\u001f'); } function areConversationRowEqual( prev: ConversationListRowProps, next: ConversationListRowProps ): boolean { if (prev.isSelected !== next.isSelected) return false; if (prev.index !== next.index) return false; if (prev.scale !== next.scale) return false; if (prev.systemChannelId !== next.systemChannelId) return false; if (conversationVisualSignature(prev.item) !== conversationVisualSignature(next.item)) return false; // onPress、formatTime 故意不比:父组件用 ref 保证行为最新 return true; } function createConversationRowStyles(colors: AppColors) { return StyleSheet.create({ conversationItem: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: spacing.md, paddingVertical: 14, backgroundColor: colors.background.default, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.chat.borderLight, }, conversationItemSelected: { backgroundColor: colors.chat.surfaceMuted, }, avatarContainer: { position: 'relative', }, systemAvatar: { width: 56, height: 56, borderRadius: borderRadius.full, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primary.main, }, conversationContent: { flex: 1, marginLeft: spacing.lg, }, conversationHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4, }, nameRow: { flexDirection: 'row', alignItems: 'center', }, officialBadge: { backgroundColor: colors.primary.main, borderRadius: borderRadius.sm, paddingHorizontal: 5, paddingVertical: 1, marginRight: spacing.xs, }, officialBadgeText: { color: colors.primary.contrast, fontSize: 9, fontWeight: '800', }, userName: { fontWeight: '600', color: colors.text.primary, fontSize: 16, letterSpacing: 0.2, }, groupIcon: { marginRight: 3, }, memberCount: { fontSize: 13, color: colors.text.secondary, marginLeft: 2, fontWeight: '500', }, pinnedIcon: { marginLeft: 5, }, groupAvatar: { width: 56, height: 56, }, timeText: { color: colors.text.hint, fontSize: 12, fontWeight: '400', }, messageRow: { flexDirection: 'row', alignItems: 'center', }, messageText: { flex: 1, color: colors.text.secondary, fontSize: 14, fontWeight: '400', lineHeight: 20, }, unreadMessageText: { color: colors.text.primary, fontWeight: '600', }, unreadBadge: { minWidth: 20, height: 20, borderRadius: 10, backgroundColor: colors.error.main, alignItems: 'center', justifyContent: 'center', marginLeft: spacing.sm, paddingHorizontal: 5, }, unreadBadgeText: { color: '#FFFFFF', fontSize: 12, fontWeight: '700', lineHeight: 20, textAlignVertical: 'center', includeFontPadding: false, }, }); } const ConversationListRowInner: React.FC = ({ item, scale, isSelected, onPress, formatTime, systemChannelId, }) => { const colors = useAppColors(); const styles = useMemo(() => createConversationRowStyles(colors), [colors]); const isSystemChannel = item.id === systemChannelId; 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; }; return ( {isSystemChannel ? ( ) : isGroupChat ? ( ) : ( )} {isSystemChannel && ( 官方 )} {isGroupChat && ( )} {displayName} {item.is_pinned && !isSystemChannel && ( )} {isGroupChat && item.member_count !== undefined && ( ({item.member_count}) )} {formatTime(item.last_message_at || item.updated_at)} 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText} numberOfLines={1} > {!item.last_message ? ( '暂无消息' ) : ( )} {item.unread_count > 0 && ( {item.unread_count > 99 ? '99+' : item.unread_count} )} ); }; ConversationListRowInner.displayName = 'ConversationListRow'; export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual);