Add `getLastMessageByConversation` to `MessageRepository` to allow fetching the most recent message for a specific conversation. Update `ConversationListRow` to use this new repository method as a fallback when message segments are missing, ensuring the conversation list preview displays the last message content correctly.
401 lines
12 KiB
TypeScript
401 lines
12 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, 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);
|