Files
frontend/src/screens/message/components/ConversationListRow.tsx

401 lines
12 KiB
TypeScript
Raw Normal View History

/**
* 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<string>('');
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<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()}
conversationId={item.id}
/>
)}
</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);