2026-03-24 05:52:24 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。
|
|
|
|
|
|
* onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
2026-03-30 17:53:30 +08:00
|
|
|
|
import { spacing, shadows, borderRadius, useAppColors, type AppColors } from '../../../theme';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import {
|
|
|
|
|
|
ConversationResponse,
|
|
|
|
|
|
extractTextFromSegments,
|
|
|
|
|
|
extractTextFromSegmentsAsync,
|
|
|
|
|
|
MessageSegment,
|
|
|
|
|
|
} from '../../../types/dto';
|
|
|
|
|
|
import { authService } from '../../../services';
|
2026-04-04 08:01:45 +08:00
|
|
|
|
import { userCacheRepository } from '@/database';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
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,
|
2026-04-04 08:01:45 +08:00
|
|
|
|
userCacheRepository.get.bind(userCacheRepository),
|
2026-03-24 05:52:24 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
function createConversationRowStyles(colors: AppColors) {
|
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
|
conversationItem: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
paddingVertical: 14,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
marginHorizontal: spacing.md,
|
|
|
|
|
|
marginTop: spacing.sm,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
borderWidth: 0.5,
|
|
|
|
|
|
borderColor: colors.divider + '40',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
conversationItemSelected: {
|
2026-03-30 17:53:30 +08:00
|
|
|
|
backgroundColor: colors.primary.light + '12',
|
|
|
|
|
|
borderColor: colors.primary.main + '60',
|
|
|
|
|
|
borderWidth: 1.5,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
avatarContainer: {
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
},
|
|
|
|
|
|
systemAvatar: {
|
|
|
|
|
|
width: 50,
|
|
|
|
|
|
height: 50,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
borderRadius: borderRadius.md,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
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,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
borderRadius: borderRadius.sm,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
officialBadgeText: {
|
|
|
|
|
|
color: colors.primary.contrast,
|
|
|
|
|
|
fontSize: 10,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '800',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
userName: {
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '700',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
fontSize: 16,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
letterSpacing: 0.3,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
groupIcon: {
|
|
|
|
|
|
marginRight: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
memberCount: {
|
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
marginLeft: 2,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '500',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
pinnedIcon: {
|
|
|
|
|
|
marginLeft: 6,
|
|
|
|
|
|
},
|
|
|
|
|
|
groupAvatar: {
|
|
|
|
|
|
width: 50,
|
|
|
|
|
|
height: 50,
|
|
|
|
|
|
},
|
|
|
|
|
|
groupAvatarPlaceholder: {
|
|
|
|
|
|
width: 50,
|
|
|
|
|
|
height: 50,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
borderRadius: borderRadius.md,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
timeText: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: 12,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '500',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
messageRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
messageText: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: 14,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '400',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
unreadMessageText: {
|
|
|
|
|
|
color: colors.text.primary,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '600',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
unreadBadge: {
|
2026-03-30 17:53:30 +08:00
|
|
|
|
minWidth: 22,
|
|
|
|
|
|
height: 22,
|
|
|
|
|
|
borderRadius: 11,
|
2026-03-25 05:16:54 +08:00
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
},
|
|
|
|
|
|
unreadBadgeText: {
|
|
|
|
|
|
color: colors.primary.contrast,
|
|
|
|
|
|
fontSize: 12,
|
2026-03-30 17:53:30 +08:00
|
|
|
|
fontWeight: '800',
|
2026-03-25 05:16:54 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
|
|
|
|
|
|
item,
|
|
|
|
|
|
scale,
|
|
|
|
|
|
isSelected,
|
|
|
|
|
|
onPress,
|
|
|
|
|
|
formatTime,
|
|
|
|
|
|
systemChannelId,
|
|
|
|
|
|
}) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = useMemo(() => createConversationRowStyles(colors), [colors]);
|
2026-03-24 05:52:24 +08:00
|
|
|
|
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}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="bell-ring" size={24} color={colors.primary.contrast} />
|
2026-03-24 05:52:24 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
) : isGroupChat ? (
|
|
|
|
|
|
<View style={styles.groupAvatar}>
|
|
|
|
|
|
{displayAvatar ? (
|
|
|
|
|
|
<Avatar source={displayAvatar} size={50} name={displayName} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<View style={styles.groupAvatarPlaceholder}>
|
2026-03-25 05:16:54 +08:00
|
|
|
|
<MaterialCommunityIcons name="account-group" size={28} color={colors.primary.contrast} />
|
2026-03-24 05:52:24 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</AnimatedTouchable>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
ConversationListRowInner.displayName = 'ConversationListRow';
|
|
|
|
|
|
|
|
|
|
|
|
export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual);
|