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.
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
/**
|
||
* 会话列表行: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);
|