refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid
- Introduced a new PostCardRelativeTime component for dynamic time display, improving user experience with real-time updates. - Refactored PostCard to utilize memoization for performance optimization and prevent unnecessary re-renders. - Removed the PostCardGrid component to streamline the codebase, consolidating functionality within the PostCard component. - Updated HomeScreen to leverage the new PostCard features, ensuring consistent post rendering and interaction handling.
This commit is contained in:
392
src/screens/message/components/ConversationListRow.tsx
Normal file
392
src/screens/message/components/ConversationListRow.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。
|
||||
* onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。
|
||||
*/
|
||||
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, shadows } from '../../../theme';
|
||||
import {
|
||||
ConversationResponse,
|
||||
extractTextFromSegments,
|
||||
extractTextFromSegmentsAsync,
|
||||
MessageSegment,
|
||||
} from '../../../types/dto';
|
||||
import { authService } from '../../../services';
|
||||
import { getUserCache } from '../../../services/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,
|
||||
getUserCache,
|
||||
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;
|
||||
}
|
||||
|
||||
const ConversationListRowInner: React.FC<ConversationListRowProps> = ({
|
||||
item,
|
||||
scale,
|
||||
isSelected,
|
||||
onPress,
|
||||
formatTime,
|
||||
systemChannelId,
|
||||
}) => {
|
||||
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={24} color="#FFF" />
|
||||
</View>
|
||||
) : isGroupChat ? (
|
||||
<View style={styles.groupAvatar}>
|
||||
{displayAvatar ? (
|
||||
<Avatar source={displayAvatar} size={50} name={displayName} />
|
||||
) : (
|
||||
<View style={styles.groupAvatarPlaceholder}>
|
||||
<MaterialCommunityIcons name="account-group" size={28} color="#FFF" />
|
||||
</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>
|
||||
)}
|
||||
{__DEV__ && (
|
||||
<Text style={{ fontSize: 10, color: '#999', marginLeft: 8 }}>[DEBUG: {item.unread_count || 0}]</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</AnimatedTouchable>
|
||||
);
|
||||
};
|
||||
|
||||
ConversationListRowInner.displayName = 'ConversationListRow';
|
||||
|
||||
export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: '#FFF',
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderRadius: 12,
|
||||
...shadows.sm,
|
||||
},
|
||||
conversationItemSelected: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 1,
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
systemAvatar: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 12,
|
||||
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,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
officialBadgeText: {
|
||||
color: '#FFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
fontSize: 16,
|
||||
},
|
||||
groupIcon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
memberCount: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginLeft: 2,
|
||||
},
|
||||
pinnedIcon: {
|
||||
marginLeft: 6,
|
||||
},
|
||||
groupAvatar: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
groupAvatarPlaceholder: {
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
timeText: {
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
},
|
||||
messageRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
messageText: {
|
||||
flex: 1,
|
||||
color: '#888',
|
||||
fontSize: 14,
|
||||
},
|
||||
unreadMessageText: {
|
||||
color: '#333',
|
||||
fontWeight: '500',
|
||||
},
|
||||
unreadBadge: {
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
unreadBadgeText: {
|
||||
color: '#FFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user