Files
frontend/src/screens/message/components/ConversationListRow.tsx
lafay 6b91a7ead1
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 53s
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
fix(message): add robust NaN validation for date handling across components
Add comprehensive date validation using Number.isNaN() checks to prevent crashes when timestamps are invalid or empty. Apply defensive formatting across CommentItem, SystemMessageItem, MessageBubble, LongPressMenu, and store utilities.

Refine message bubble styling: move sender name display to both sides in group chat with distinct `mySenderName` styling, adjust bubble corner radius to top-right for consistent arrow placement, and align message rows to flex-start for improved layout. Simplify group avatar rendering in ConversationListRow.
2026-04-25 15:25:42 +08:00

385 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 会话列表行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 } 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;
}> = ({ 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,
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]);
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<ConversationListRowProps> = ({
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 (
<AnimatedTouchable
style={[
styles.conversationItem,
isSelected && styles.conversationItemSelected,
{ transform: [{ scale }] },
]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.avatarContainer}>
{isSystemChannel ? (
<View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={26} color={colors.primary.contrast} />
</View>
) : isGroupChat ? (
<View style={styles.groupAvatar}>
<Avatar source={displayAvatar} size={56} name={displayNameRaw || '群'} />
</View>
) : (
<Avatar source={displayAvatar} size={56} 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>
)}
</View>
</View>
</AnimatedTouchable>
);
};
ConversationListRowInner.displayName = 'ConversationListRow';
export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual);