2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
2026-03-25 01:29:41 +08:00
|
|
|
|
* CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
|
2026-03-09 21:29:03 +08:00
|
|
|
|
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import React, { useMemo, useState } from 'react';
|
2026-03-25 01:29:41 +08:00
|
|
|
|
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
|
|
import { zhCN } from 'date-fns/locale';
|
2026-03-25 05:16:54 +08:00
|
|
|
|
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import { Comment, CommentImage } from '../../types';
|
|
|
|
|
|
import Text from '../common/Text';
|
|
|
|
|
|
import Avatar from '../common/Avatar';
|
|
|
|
|
|
import { CompactImageGrid, ImageGridItem } from '../common';
|
|
|
|
|
|
|
|
|
|
|
|
interface CommentItemProps {
|
|
|
|
|
|
comment: Comment;
|
|
|
|
|
|
onUserPress: () => void;
|
|
|
|
|
|
onReply: () => void;
|
2026-03-26 01:25:42 +08:00
|
|
|
|
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
2026-03-09 21:29:03 +08:00
|
|
|
|
floorNumber?: number; // 楼层号
|
|
|
|
|
|
isAuthor?: boolean; // 是否是楼主
|
|
|
|
|
|
replyToUser?: string; // 回复给哪位用户
|
|
|
|
|
|
onReplyPress?: (comment: Comment) => void; // 点击评论的评论
|
|
|
|
|
|
allReplies?: Comment[]; // 所有回复列表,用于根据 target_id 查找被回复用户
|
|
|
|
|
|
onLoadMoreReplies?: (commentId: string) => void; // 加载更多回复的回调
|
|
|
|
|
|
isCommentAuthor?: boolean; // 当前用户是否为评论作者
|
|
|
|
|
|
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
|
|
|
|
|
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
|
|
|
|
|
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
2026-03-29 20:18:49 +08:00
|
|
|
|
onReport?: (comment: Comment) => void; // 举报评论的回调
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 05:16:54 +08:00
|
|
|
|
function createCommentItemStyles(colors: AppColors) {
|
|
|
|
|
|
return StyleSheet.create({
|
|
|
|
|
|
container: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
paddingTop: spacing.md,
|
|
|
|
|
|
paddingBottom: spacing.md,
|
|
|
|
|
|
paddingHorizontal: spacing.md,
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderBottomColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
content: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
header: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
gap: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
userInfo: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
metaDot: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
marginHorizontal: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
username: {
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
badge: {
|
|
|
|
|
|
paddingHorizontal: 4,
|
|
|
|
|
|
paddingVertical: 1,
|
|
|
|
|
|
borderRadius: 2,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
smallBadge: {
|
|
|
|
|
|
paddingHorizontal: 2,
|
|
|
|
|
|
paddingVertical: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorBadge: {
|
|
|
|
|
|
backgroundColor: colors.primary.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
adminBadge: {
|
|
|
|
|
|
backgroundColor: colors.error.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
badgeText: {
|
|
|
|
|
|
color: colors.text.inverse,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
smallBadgeText: {
|
|
|
|
|
|
color: colors.text.inverse,
|
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
timeText: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
floorPlain: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
replyReference: {
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
commentContent: {
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
text: {
|
|
|
|
|
|
lineHeight: 22,
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
},
|
|
|
|
|
|
actions: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginTop: spacing.xs,
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
},
|
|
|
|
|
|
actionButton: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginRight: spacing.lg,
|
|
|
|
|
|
paddingVertical: 4,
|
|
|
|
|
|
paddingRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionText: {
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
subRepliesContainer: {
|
|
|
|
|
|
marginTop: spacing.sm,
|
|
|
|
|
|
marginLeft: 0,
|
|
|
|
|
|
paddingLeft: spacing.sm,
|
|
|
|
|
|
paddingVertical: spacing.xs,
|
|
|
|
|
|
borderLeftWidth: 2,
|
|
|
|
|
|
borderLeftColor: colors.divider,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyItem: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
marginBottom: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyItemLast: {
|
|
|
|
|
|
marginBottom: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyBody: {
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
lineHeight: 20,
|
|
|
|
|
|
marginTop: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyContent: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
marginLeft: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyHeader: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginBottom: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyAuthor: {
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
marginRight: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
moreRepliesButton: {
|
|
|
|
|
|
marginTop: spacing.xs,
|
|
|
|
|
|
paddingVertical: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
replyToText: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
replyToName: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '500',
|
|
|
|
|
|
},
|
|
|
|
|
|
subReplyActions: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginTop: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
subActionButton: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginRight: spacing.sm,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
subActionText: {
|
|
|
|
|
|
marginLeft: 2,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const CommentItem: React.FC<CommentItemProps> = ({
|
|
|
|
|
|
comment,
|
|
|
|
|
|
onUserPress,
|
|
|
|
|
|
onReply,
|
|
|
|
|
|
onLike,
|
|
|
|
|
|
floorNumber,
|
|
|
|
|
|
isAuthor = false,
|
|
|
|
|
|
replyToUser,
|
|
|
|
|
|
onReplyPress,
|
|
|
|
|
|
allReplies,
|
|
|
|
|
|
onLoadMoreReplies,
|
|
|
|
|
|
isCommentAuthor = false,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onImagePress,
|
|
|
|
|
|
currentUserId,
|
|
|
|
|
|
}) => {
|
2026-03-25 05:16:54 +08:00
|
|
|
|
const colors = useAppColors();
|
|
|
|
|
|
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
|
|
// 格式化时间
|
|
|
|
|
|
const formatTime = (dateString: string): string => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return formatDistanceToNow(new Date(dateString), {
|
|
|
|
|
|
addSuffix: true,
|
|
|
|
|
|
locale: zhCN,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化数字
|
|
|
|
|
|
const formatNumber = (num: number): string => {
|
|
|
|
|
|
if (num >= 10000) {
|
|
|
|
|
|
return (num / 10000).toFixed(1) + 'w';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (num >= 1000) {
|
|
|
|
|
|
return (num / 1000).toFixed(1) + 'k';
|
|
|
|
|
|
}
|
|
|
|
|
|
return num.toString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理图片点击
|
|
|
|
|
|
const handleImagePress = (index: number) => {
|
|
|
|
|
|
if (onImagePress && comment.images && comment.images.length > 0) {
|
|
|
|
|
|
const images: ImageGridItem[] = comment.images.map(img => ({
|
|
|
|
|
|
id: img.url,
|
|
|
|
|
|
url: img.url,
|
|
|
|
|
|
}));
|
|
|
|
|
|
onImagePress(images, index);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染评论图片 - 使用 CompactImageGrid
|
|
|
|
|
|
const renderCommentImages = () => {
|
|
|
|
|
|
if (!comment.images || comment.images.length === 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const gridImages: ImageGridItem[] = comment.images.map(img => ({
|
|
|
|
|
|
id: img.url,
|
|
|
|
|
|
url: img.url,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CompactImageGrid
|
|
|
|
|
|
images={gridImages}
|
|
|
|
|
|
maxDisplayCount={6}
|
|
|
|
|
|
gap={4}
|
|
|
|
|
|
borderRadius={borderRadius.sm}
|
|
|
|
|
|
showMoreOverlay
|
|
|
|
|
|
onImagePress={onImagePress}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理删除评论
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
if (!onDelete || isDeleting) return;
|
|
|
|
|
|
|
|
|
|
|
|
Alert.alert(
|
|
|
|
|
|
'删除评论',
|
|
|
|
|
|
'确定要删除这条评论吗?删除后将无法恢复。',
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '取消',
|
|
|
|
|
|
style: 'cancel',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '删除',
|
|
|
|
|
|
style: 'destructive',
|
|
|
|
|
|
onPress: async () => {
|
|
|
|
|
|
setIsDeleting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await onDelete(comment);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除评论失败:', error);
|
|
|
|
|
|
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeleting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染身份标识
|
|
|
|
|
|
const renderBadges = () => {
|
|
|
|
|
|
const badges = [];
|
|
|
|
|
|
const authorId = comment.author?.id || '';
|
|
|
|
|
|
|
|
|
|
|
|
if (isAuthor) {
|
|
|
|
|
|
badges.push(
|
|
|
|
|
|
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
|
|
|
|
|
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (authorId === '1') { // 管理员
|
|
|
|
|
|
badges.push(
|
|
|
|
|
|
<View key="admin" style={[styles.badge, styles.adminBadge]}>
|
|
|
|
|
|
<Text variant="caption" style={styles.badgeText}>管理员</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return badges;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染楼层号
|
|
|
|
|
|
const renderFloorNumber = () => {
|
|
|
|
|
|
if (!floorNumber) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 获取楼层显示文本
|
|
|
|
|
|
const getFloorText = (floor: number): string => {
|
|
|
|
|
|
switch (floor) {
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
return '沙发';
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
return '板凳';
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
return '地板';
|
|
|
|
|
|
default:
|
|
|
|
|
|
return `${floor}楼`;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-25 01:29:41 +08:00
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
|
|
|
|
|
|
{getFloorText(floorNumber)}
|
|
|
|
|
|
</Text>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 根据 target_id 查找被回复用户的昵称
|
|
|
|
|
|
const getTargetUserNickname = (targetId: string): string => {
|
|
|
|
|
|
// 首先在顶级评论中查找
|
|
|
|
|
|
if (comment.id === targetId) {
|
|
|
|
|
|
return comment.author?.nickname || '用户';
|
|
|
|
|
|
}
|
|
|
|
|
|
// 然后在回复列表中查找
|
|
|
|
|
|
if (allReplies) {
|
|
|
|
|
|
const targetComment = allReplies.find(r => r.id === targetId);
|
|
|
|
|
|
if (targetComment) {
|
|
|
|
|
|
return targetComment.author?.nickname || '用户';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 最后在当前评论的 replies 中查找
|
|
|
|
|
|
if (comment.replies) {
|
|
|
|
|
|
const targetComment = comment.replies.find(r => r.id === targetId);
|
|
|
|
|
|
if (targetComment) {
|
|
|
|
|
|
return targetComment.author?.nickname || '用户';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return '用户';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染子评论图片
|
|
|
|
|
|
const renderSubReplyImages = (reply: Comment) => {
|
|
|
|
|
|
if (!reply.images || reply.images.length === 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const gridImages: ImageGridItem[] = reply.images.map(img => ({
|
|
|
|
|
|
id: img.url,
|
|
|
|
|
|
url: img.url,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<CompactImageGrid
|
|
|
|
|
|
images={gridImages}
|
|
|
|
|
|
maxDisplayCount={3}
|
|
|
|
|
|
gap={4}
|
|
|
|
|
|
borderRadius={borderRadius.sm}
|
|
|
|
|
|
showMoreOverlay
|
|
|
|
|
|
onImagePress={onImagePress}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理子评论删除
|
|
|
|
|
|
const handleSubReplyDelete = (reply: Comment) => {
|
|
|
|
|
|
if (!onDelete || isDeleting) return;
|
|
|
|
|
|
|
|
|
|
|
|
Alert.alert(
|
|
|
|
|
|
'删除回复',
|
|
|
|
|
|
'确定要删除这条回复吗?删除后将无法恢复。',
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '取消',
|
|
|
|
|
|
style: 'cancel',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '删除',
|
|
|
|
|
|
style: 'destructive',
|
|
|
|
|
|
onPress: async () => {
|
|
|
|
|
|
setIsDeleting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await onDelete(reply);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除回复失败:', error);
|
|
|
|
|
|
Alert.alert('删除失败', '删除回复时发生错误,请稍后重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeleting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染评论的评论(子评论)- 抖音/b站风格:平铺展示
|
|
|
|
|
|
const renderSubReplies = () => {
|
|
|
|
|
|
if (!comment.replies || comment.replies.length === 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const commentAuthorId = comment.author?.id || '';
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.subRepliesContainer}>
|
2026-03-25 01:29:41 +08:00
|
|
|
|
{comment.replies.map((reply, replyIndex) => {
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const replyAuthorId = reply.author?.id || '';
|
2026-03-25 01:29:41 +08:00
|
|
|
|
const isLastReply = replyIndex === comment.replies!.length - 1;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 根据 target_id 获取被回复的用户昵称
|
|
|
|
|
|
const targetId = reply.target_id;
|
|
|
|
|
|
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
|
|
|
|
|
// 判断当前用户是否为子评论作者
|
|
|
|
|
|
const isSubReplyAuthor = currentUserId === replyAuthorId;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
key={reply.id}
|
2026-03-25 01:29:41 +08:00
|
|
|
|
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onPress={() => onReplyPress?.(reply)}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Avatar
|
|
|
|
|
|
source={reply.author?.avatar}
|
|
|
|
|
|
size={24}
|
|
|
|
|
|
name={reply.author?.nickname || '用户'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<View style={styles.subReplyContent}>
|
|
|
|
|
|
<View style={styles.subReplyHeader}>
|
|
|
|
|
|
<Text variant="caption" style={styles.subReplyAuthor}>
|
|
|
|
|
|
{reply.author?.nickname || '用户'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{replyAuthorId === commentAuthorId && (
|
|
|
|
|
|
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
|
|
|
|
|
|
<Text variant="caption" style={styles.smallBadgeText}>楼主</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* 显示回复引用:aaa 回复 bbb */}
|
|
|
|
|
|
{targetNickname && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
|
|
|
|
|
{' '}回复
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
|
|
|
|
|
{targetNickname}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
{/* 显示回复内容(如果有文字) */}
|
|
|
|
|
|
{reply.content ? (
|
2026-03-25 01:29:41 +08:00
|
|
|
|
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{reply.content}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{/* 显示回复图片(如果有图片) */}
|
|
|
|
|
|
{renderSubReplyImages(reply)}
|
|
|
|
|
|
{/* 子评论操作按钮 */}
|
|
|
|
|
|
<View style={styles.subReplyActions}>
|
|
|
|
|
|
<TouchableOpacity
|
2026-03-24 14:21:31 +08:00
|
|
|
|
style={styles.subActionButton}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onPress={() => onReplyPress?.(reply)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
2026-03-24 14:21:31 +08:00
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
回复
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-26 01:25:42 +08:00
|
|
|
|
{/* 点赞按钮 */}
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.subActionButton}
|
|
|
|
|
|
onPress={() => onLike?.(reply)}
|
|
|
|
|
|
activeOpacity={0.7}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={reply.is_liked ? 'heart' : 'heart-outline'}
|
|
|
|
|
|
size={12}
|
|
|
|
|
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="caption"
|
|
|
|
|
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
|
|
|
|
|
style={styles.subActionText}
|
|
|
|
|
|
>
|
|
|
|
|
|
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{/* 删除按钮 - 子评论作者可见 */}
|
|
|
|
|
|
{isSubReplyAuthor && (
|
|
|
|
|
|
<TouchableOpacity
|
2026-03-24 14:21:31 +08:00
|
|
|
|
style={styles.subActionButton}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onPress={() => handleSubReplyDelete(reply)}
|
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
|
|
|
|
|
size={12}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
2026-03-24 14:21:31 +08:00
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
{isDeleting ? '删除中' : '删除'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{comment.replies_count > (comment.replies?.length || 0) && (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.moreRepliesButton}
|
|
|
|
|
|
onPress={() => onLoadMoreReplies?.(comment.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text variant="caption" color={colors.primary.main}>
|
|
|
|
|
|
展开剩余 {comment.replies_count - (comment.replies?.length || 0)} 条回复
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View style={styles.container}>
|
2026-03-25 01:29:41 +08:00
|
|
|
|
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
|
|
|
|
|
|
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Avatar
|
|
|
|
|
|
source={comment.author?.avatar}
|
2026-03-25 01:29:41 +08:00
|
|
|
|
size={40}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
name={comment.author?.nickname}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论内容 */}
|
|
|
|
|
|
<View style={styles.content}>
|
|
|
|
|
|
{/* 用户信息行 - QQ频道风格 */}
|
|
|
|
|
|
<View style={styles.header}>
|
|
|
|
|
|
<View style={styles.userInfo}>
|
2026-03-25 01:29:41 +08:00
|
|
|
|
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
|
|
|
|
|
|
<Text variant="body" style={styles.username} numberOfLines={1}>
|
|
|
|
|
|
{comment.author?.nickname || '用户'}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
{renderBadges()}
|
2026-03-25 01:29:41 +08:00
|
|
|
|
<Text style={styles.metaDot}>·</Text>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
|
|
|
|
|
{formatTime(comment.created_at || '')}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
{renderFloorNumber()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 回复引用 */}
|
|
|
|
|
|
{replyToUser && (
|
|
|
|
|
|
<View style={styles.replyReference}>
|
|
|
|
|
|
<Text variant="caption" color={colors.primary.main}>
|
|
|
|
|
|
回复 @{replyToUser}:
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论文本 - 非气泡样式 */}
|
|
|
|
|
|
<View style={styles.commentContent}>
|
|
|
|
|
|
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
|
|
|
|
|
{comment.content}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论图片 */}
|
|
|
|
|
|
{renderCommentImages()}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 操作按钮 - 更紧凑 */}
|
|
|
|
|
|
<View style={styles.actions}>
|
|
|
|
|
|
{/* 点赞 */}
|
2026-03-26 01:25:42 +08:00
|
|
|
|
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
color={comment.is_liked ? colors.error.main : colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
variant="caption"
|
|
|
|
|
|
color={comment.is_liked ? colors.error.main : colors.text.hint}
|
|
|
|
|
|
style={styles.actionText}
|
|
|
|
|
|
>
|
|
|
|
|
|
{comment.likes_count > 0 ? formatNumber(comment.likes_count) : '赞'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 回复 */}
|
|
|
|
|
|
<TouchableOpacity style={styles.actionButton} onPress={onReply}>
|
|
|
|
|
|
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
|
|
|
|
|
回复
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 删除按钮 - 只对评论作者显示 */}
|
|
|
|
|
|
{isCommentAuthor && (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={styles.actionButton}
|
|
|
|
|
|
onPress={handleDelete}
|
|
|
|
|
|
disabled={isDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
|
|
|
|
|
size={14}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
|
|
|
|
|
{isDeleting ? '删除中' : '删除'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 评论的评论(子评论)- 抖音/b站风格:平铺展示不开新层级 */}
|
|
|
|
|
|
{renderSubReplies()}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default CommentItem;
|