Files
frontend/src/screens/message/components/ConversationListRow.tsx
lan c0ebebe3da
All checks were successful
Frontend CI / ota-android (push) Successful in 1m16s
Frontend CI / build-and-push-web (push) Successful in 2m53s
Frontend CI / build-android-apk (push) Successful in 34m6s
feat(db): add getLastMessageByConversation and update conversation list preview
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.
2026-05-05 02:01:05 +08:00

401 lines
12 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, 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);