2026-03-24 03:02:54 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* PostCard 主组件
|
2026-03-24 04:23:13 +08:00
|
|
|
|
* 帖子卡片组件(新实现)
|
2026-03-24 03:02:54 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* // 新 API 使用方式
|
|
|
|
|
|
* <PostCard
|
|
|
|
|
|
* post={post}
|
|
|
|
|
|
* onAction={(action) => handleAction(post, action)}
|
|
|
|
|
|
* variant="list"
|
|
|
|
|
|
* features="full"
|
|
|
|
|
|
* />
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import React, { useMemo, useState, memo, useEffect } from 'react';
|
|
|
|
|
|
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
2026-03-24 04:23:13 +08:00
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { PostCardProps, PostCardAction } from './types';
|
|
|
|
|
|
import { ImageGridItem, SmartImage } from '../../common';
|
|
|
|
|
|
import Text from '../../common/Text';
|
|
|
|
|
|
import Avatar from '../../common/Avatar';
|
|
|
|
|
|
import { colors, spacing, borderRadius, fontSizes } from '../../../theme';
|
|
|
|
|
|
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
|
|
|
|
|
import PostImages from './components/PostImages';
|
|
|
|
|
|
import { useResponsive } from '../../../hooks/useResponsive';
|
2026-03-24 05:52:24 +08:00
|
|
|
|
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
|
|
|
|
|
|
|
|
|
|
|
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
|
|
|
|
|
const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({
|
|
|
|
|
|
createdAt,
|
|
|
|
|
|
style: timeStyle,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt));
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setLabel(formatPostCreatedAtString(createdAt));
|
|
|
|
|
|
const tick = () => setLabel(formatPostCreatedAtString(createdAt));
|
|
|
|
|
|
const id = setInterval(tick, 60_000);
|
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
|
}, [createdAt]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Text style={timeStyle} numberOfLines={1}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function imagesSignature(
|
|
|
|
|
|
images: PostCardProps['post']['images'] | undefined
|
|
|
|
|
|
): string {
|
|
|
|
|
|
if (!images?.length) return '';
|
|
|
|
|
|
return images.map((i) => i.id).join('\u001f');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function authorSignature(author: PostCardProps['post']['author']): string {
|
|
|
|
|
|
if (!author) return '';
|
|
|
|
|
|
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function topCommentSignature(tc: PostCardProps['post']['top_comment']): string {
|
|
|
|
|
|
if (!tc) return '';
|
|
|
|
|
|
return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 01:29:41 +08:00
|
|
|
|
function channelSignature(ch: PostCardProps['post']['channel']): string {
|
|
|
|
|
|
if (!ch) return '';
|
|
|
|
|
|
return [ch.id, ch.name ?? ''].join('\u001f');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
function featuresComparable(f: PostCardProps['features']): string {
|
|
|
|
|
|
if (f == null) return '';
|
|
|
|
|
|
if (typeof f === 'string') return f;
|
|
|
|
|
|
return JSON.stringify(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post,避免 Tab 切换时全列表因回调重建而重绘)。
|
|
|
|
|
|
* index 未参与 UI,不比较以免列表重排时多余更新。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean {
|
|
|
|
|
|
if (prev.variant !== next.variant) return false;
|
|
|
|
|
|
if (prev.isPostAuthor !== next.isPostAuthor) return false;
|
|
|
|
|
|
if (prev.isAuthor !== next.isAuthor) return false;
|
|
|
|
|
|
if (prev.style !== next.style) return false;
|
|
|
|
|
|
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const a = prev.post;
|
|
|
|
|
|
const b = next.post;
|
|
|
|
|
|
if (a === b) return true;
|
|
|
|
|
|
|
|
|
|
|
|
if (a.id !== b.id) return false;
|
|
|
|
|
|
if (a.title !== b.title) return false;
|
|
|
|
|
|
if (a.content !== b.content) return false;
|
|
|
|
|
|
if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false;
|
|
|
|
|
|
if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false;
|
|
|
|
|
|
if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false;
|
|
|
|
|
|
if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false;
|
|
|
|
|
|
if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false;
|
|
|
|
|
|
if (!!a.is_pinned !== !!b.is_pinned) return false;
|
|
|
|
|
|
if (!!a.is_locked !== !!b.is_locked) return false;
|
|
|
|
|
|
if (!!a.is_vote !== !!b.is_vote) return false;
|
|
|
|
|
|
if (a.created_at !== b.created_at) return false;
|
|
|
|
|
|
if (a.updated_at !== b.updated_at) return false;
|
|
|
|
|
|
if (a.content_edited_at !== b.content_edited_at) return false;
|
|
|
|
|
|
if (!!a.is_liked !== !!b.is_liked) return false;
|
|
|
|
|
|
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
|
|
|
|
|
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
|
|
|
|
|
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
|
|
|
|
|
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
2026-03-25 01:29:41 +08:00
|
|
|
|
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
2026-03-24 05:52:24 +08:00
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-03-24 03:02:54 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* PostCard 主组件
|
2026-03-24 04:23:13 +08:00
|
|
|
|
* 仅支持新 API
|
2026-03-24 03:02:54 +08:00
|
|
|
|
*/
|
2026-03-24 05:52:24 +08:00
|
|
|
|
const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
2026-03-24 03:02:54 +08:00
|
|
|
|
const {
|
|
|
|
|
|
post,
|
|
|
|
|
|
onAction,
|
|
|
|
|
|
variant = 'list',
|
2026-03-24 04:23:13 +08:00
|
|
|
|
features,
|
2026-03-24 03:02:54 +08:00
|
|
|
|
isPostAuthor = false,
|
|
|
|
|
|
isAuthor = false,
|
|
|
|
|
|
index,
|
|
|
|
|
|
style,
|
|
|
|
|
|
} = normalizedProps;
|
|
|
|
|
|
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const isCompact =
|
|
|
|
|
|
features === 'compact' ||
|
|
|
|
|
|
(typeof features === 'object' && features?.showContent === false);
|
|
|
|
|
|
|
|
|
|
|
|
const emit = (action: PostCardAction) => {
|
|
|
|
|
|
onAction(action);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImagePress = (images: ImageGridItem[], imageIndex: number) => {
|
|
|
|
|
|
emit({ type: 'imagePress', payload: { images, imageIndex } });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const showGrid = variant === 'grid';
|
|
|
|
|
|
const author = post.author;
|
|
|
|
|
|
const rawContent = post.content ?? '';
|
|
|
|
|
|
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
|
|
|
|
|
|
|
|
|
|
|
const handleCardPress = () => emit({ type: 'press' });
|
|
|
|
|
|
const handleUserPress = () => emit({ type: 'userPress' });
|
|
|
|
|
|
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
|
|
|
|
|
const handleComment = () => emit({ type: 'comment' });
|
|
|
|
|
|
const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' });
|
|
|
|
|
|
const handleShare = () => emit({ type: 'share' });
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
if (isDeleting) return;
|
|
|
|
|
|
Alert.alert(
|
|
|
|
|
|
'删除帖子',
|
|
|
|
|
|
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
|
|
|
|
|
[
|
|
|
|
|
|
{ text: '取消', style: 'cancel' },
|
|
|
|
|
|
{
|
|
|
|
|
|
text: '删除',
|
|
|
|
|
|
style: 'destructive',
|
|
|
|
|
|
onPress: async () => {
|
|
|
|
|
|
setIsDeleting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
emit({ type: 'delete' });
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeleting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const images: ImageGridItem[] = Array.isArray(post.images)
|
|
|
|
|
|
? post.images.map((img) => ({
|
|
|
|
|
|
id: img.id,
|
|
|
|
|
|
url: img.url,
|
|
|
|
|
|
thumbnail_url: img.thumbnail_url,
|
|
|
|
|
|
preview_url: img.preview_url,
|
|
|
|
|
|
preview_url_large: img.preview_url_large,
|
|
|
|
|
|
width: img.width,
|
|
|
|
|
|
height: img.height,
|
|
|
|
|
|
}))
|
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
|
|
const formatNumber = (num: number): string => {
|
|
|
|
|
|
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
|
|
|
|
|
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
|
|
|
|
|
|
return String(num);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getResponsiveMaxLength = () => {
|
|
|
|
|
|
if (isWideScreen) return 300;
|
|
|
|
|
|
if (isDesktop) return 250;
|
|
|
|
|
|
if (isTablet) return 200;
|
|
|
|
|
|
return 100;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const contentNumberOfLines = useMemo(() => {
|
|
|
|
|
|
if (isWideScreen) return 8;
|
|
|
|
|
|
if (isDesktop) return 6;
|
|
|
|
|
|
if (isTablet) return 5;
|
|
|
|
|
|
return 3;
|
|
|
|
|
|
}, [isWideScreen, isDesktop, isTablet]);
|
|
|
|
|
|
|
|
|
|
|
|
const getTruncatedContent = (value: string): string => {
|
|
|
|
|
|
const maxLength = getResponsiveMaxLength();
|
|
|
|
|
|
if (value.length <= maxLength || isExpanded) return value;
|
|
|
|
|
|
return `${value.substring(0, maxLength)}...`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
|
|
|
|
|
const content = useMemo(
|
|
|
|
|
|
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
|
|
|
|
|
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderImages = () => {
|
|
|
|
|
|
if (images.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
if (showGrid) {
|
|
|
|
|
|
const cover = images[0];
|
|
|
|
|
|
const coverUrl = getPreviewImageUrl(cover as any, 'grid');
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
|
|
|
|
|
<SmartImage
|
|
|
|
|
|
source={{ uri: coverUrl || cover.url || '' }}
|
|
|
|
|
|
style={styles.gridCover}
|
|
|
|
|
|
resizeMode="cover"
|
|
|
|
|
|
usePreview={true}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-24 03:02:54 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-24 04:23:13 +08:00
|
|
|
|
<PostImages
|
|
|
|
|
|
images={post.images || []}
|
|
|
|
|
|
displayMode="list"
|
|
|
|
|
|
onImagePress={handleImagePress}
|
2026-03-24 03:02:54 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderTopComment = () => {
|
|
|
|
|
|
if (showGrid || !post.top_comment) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity style={styles.topCommentBox} activeOpacity={0.85} onPress={handleComment}>
|
|
|
|
|
|
<Text style={styles.topCommentAuthor} numberOfLines={1}>
|
|
|
|
|
|
{post.top_comment.author?.nickname || '匿名用户'}:
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text style={styles.topCommentText} numberOfLines={1}>
|
|
|
|
|
|
{post.top_comment.content}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderGridCard = () => {
|
|
|
|
|
|
const cover = images[0];
|
|
|
|
|
|
const hasImage = !!cover;
|
|
|
|
|
|
const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : '';
|
|
|
|
|
|
const contentPreview = rawContent
|
|
|
|
|
|
.replace(/[#*_~`\[\]()]/g, '')
|
|
|
|
|
|
.replace(/\n+/g, ' ')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
activeOpacity={0.9}
|
|
|
|
|
|
onPress={handleCardPress}
|
|
|
|
|
|
style={[styles.gridRootCard, style]}
|
|
|
|
|
|
>
|
|
|
|
|
|
{hasImage ? (
|
|
|
|
|
|
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
|
|
|
|
|
|
<SmartImage
|
|
|
|
|
|
source={{ uri: coverUrl || cover.url || '' }}
|
|
|
|
|
|
style={styles.gridCover}
|
|
|
|
|
|
resizeMode="cover"
|
|
|
|
|
|
usePreview={true}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<View style={styles.gridNoImagePreview}>
|
|
|
|
|
|
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
|
|
|
|
|
{contentPreview}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{post.is_vote && (
|
|
|
|
|
|
<View style={styles.gridVoteTag}>
|
|
|
|
|
|
<MaterialCommunityIcons name="vote" size={11} color={colors.primary.contrast} />
|
|
|
|
|
|
<Text style={styles.gridVoteTagText}>投票</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!!post.title && (
|
|
|
|
|
|
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
|
|
|
|
|
{post.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-25 01:29:41 +08:00
|
|
|
|
{!!post.channel?.name && (
|
|
|
|
|
|
<View style={styles.gridChannelRow}>
|
|
|
|
|
|
<MaterialCommunityIcons name="tag-outline" size={12} color={colors.text.hint} />
|
|
|
|
|
|
<Text style={styles.gridChannelText} numberOfLines={1}>
|
|
|
|
|
|
{post.channel.name}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-24 04:23:13 +08:00
|
|
|
|
<View style={styles.gridFooter}>
|
|
|
|
|
|
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
|
|
|
|
|
|
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
|
|
|
|
|
|
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<View style={styles.gridLikeArea}>
|
|
|
|
|
|
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
|
|
|
|
|
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-03-24 03:02:54 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-24 04:23:13 +08:00
|
|
|
|
showGrid ? renderGridCard() : (
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
activeOpacity={0.9}
|
|
|
|
|
|
onPress={handleCardPress}
|
|
|
|
|
|
style={[styles.card, style]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View style={styles.header}>
|
|
|
|
|
|
<TouchableOpacity onPress={handleUserPress} activeOpacity={0.8} style={styles.authorWrap}>
|
|
|
|
|
|
<Avatar source={author?.avatar} size={showGrid ? 22 : 36} name={author?.nickname || '匿名用户'} />
|
|
|
|
|
|
<View style={styles.authorMeta}>
|
|
|
|
|
|
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
|
|
|
|
|
{!showGrid && (
|
|
|
|
|
|
<View style={styles.metaRow}>
|
2026-03-24 05:52:24 +08:00
|
|
|
|
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
|
2026-03-25 01:29:41 +08:00
|
|
|
|
{!!post.channel?.name && (
|
|
|
|
|
|
<View style={styles.channelTag}>
|
|
|
|
|
|
<MaterialCommunityIcons name="tag-outline" size={11} color={colors.primary.main} />
|
|
|
|
|
|
<Text style={styles.channelTagText} numberOfLines={1}>
|
|
|
|
|
|
{post.channel.name}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
2026-03-24 04:23:13 +08:00
|
|
|
|
{post.is_pinned && (
|
|
|
|
|
|
<View style={styles.pinnedTag}>
|
|
|
|
|
|
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
|
|
|
|
|
|
<Text style={styles.pinnedText}>置顶</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isAuthor && <Text style={styles.badgeText}>楼主</Text>}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
{isPostAuthor && (
|
|
|
|
|
|
<TouchableOpacity onPress={handleDelete} hitSlop={8} style={styles.deleteButton} disabled={isDeleting}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
|
|
|
|
|
size={isDesktop ? 20 : 18}
|
|
|
|
|
|
color={colors.text.hint}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{!!post.title && (
|
|
|
|
|
|
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
|
|
|
|
|
{post.title}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!isCompact && !!content && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Text
|
|
|
|
|
|
style={styles.content}
|
|
|
|
|
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
|
|
|
|
|
>
|
|
|
|
|
|
{content}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
{shouldTruncate && (
|
|
|
|
|
|
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
|
|
|
|
|
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{renderImages()}
|
|
|
|
|
|
{renderTopComment()}
|
|
|
|
|
|
|
|
|
|
|
|
<View style={styles.actions}>
|
|
|
|
|
|
<View style={styles.viewsWrap}>
|
|
|
|
|
|
<MaterialCommunityIcons name="eye-outline" size={16} color={colors.text.hint} />
|
|
|
|
|
|
<Text style={styles.viewsText}>{post.views_count || 0}</Text>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
<View style={styles.actionButtons}>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={post.is_liked ? 'heart' : 'heart-outline'}
|
|
|
|
|
|
size={18}
|
|
|
|
|
|
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
|
|
|
|
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionBtn} onPress={handleComment}>
|
|
|
|
|
|
<MaterialCommunityIcons name="comment-outline" size={18} color={colors.text.secondary} />
|
|
|
|
|
|
<Text style={styles.actionText}>{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}</Text>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
|
|
|
|
|
|
<MaterialCommunityIcons name="share-outline" size={18} color={colors.text.secondary} />
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
|
|
|
|
|
size={18}
|
|
|
|
|
|
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
)
|
2026-03-24 03:02:54 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-24 05:52:24 +08:00
|
|
|
|
PostCardInner.displayName = 'PostCard';
|
|
|
|
|
|
|
|
|
|
|
|
const PostCard = memo(PostCardInner, arePostCardPropsEqual);
|
|
|
|
|
|
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
card: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
padding: spacing.md,
|
|
|
|
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
header: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
marginBottom: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorWrap: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorMeta: {
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
authorName: {
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
timeText: {
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
marginTop: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
metaRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 6,
|
|
|
|
|
|
marginTop: 2,
|
2026-03-25 01:29:41 +08:00
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
},
|
|
|
|
|
|
channelTag: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
maxWidth: 140,
|
|
|
|
|
|
paddingHorizontal: 6,
|
|
|
|
|
|
paddingVertical: 2,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
backgroundColor: `${colors.primary.main}12`,
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
channelTagText: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
flexShrink: 1,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
},
|
|
|
|
|
|
pinnedTag: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
backgroundColor: `${colors.warning.light}22`,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
paddingHorizontal: 4,
|
|
|
|
|
|
paddingVertical: 1,
|
|
|
|
|
|
gap: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
pinnedText: {
|
|
|
|
|
|
color: colors.warning.main,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
badgeText: {
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
deleteButton: {
|
|
|
|
|
|
padding: spacing.xs,
|
|
|
|
|
|
marginLeft: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
title: {
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
fontSize: fontSizes.lg,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
},
|
|
|
|
|
|
content: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
marginBottom: spacing.xs,
|
|
|
|
|
|
lineHeight: fontSizes.md * 1.45,
|
|
|
|
|
|
},
|
|
|
|
|
|
expandBtn: {
|
|
|
|
|
|
marginBottom: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
expandText: {
|
|
|
|
|
|
color: colors.primary.main,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
topCommentBox: {
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
borderRadius: borderRadius.sm,
|
|
|
|
|
|
paddingHorizontal: spacing.sm,
|
|
|
|
|
|
paddingVertical: spacing.xs,
|
|
|
|
|
|
marginBottom: spacing.sm,
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
topCommentAuthor: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
marginRight: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
topCommentText: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridCover: {
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
aspectRatio: 0.78,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
actions: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
viewsWrap: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
viewsText: {
|
|
|
|
|
|
color: colors.text.hint,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionButtons: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
actionBtn: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginLeft: spacing.md,
|
|
|
|
|
|
},
|
|
|
|
|
|
actionText: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
activeActionText: {
|
|
|
|
|
|
color: colors.error.main,
|
|
|
|
|
|
},
|
|
|
|
|
|
activeActionTextMerged: {
|
|
|
|
|
|
color: colors.error.main,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridRootCard: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
borderRadius: borderRadius.lg,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
|
|
|
|
borderColor: colors.divider,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridNoImagePreview: {
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
margin: 6,
|
|
|
|
|
|
borderRadius: borderRadius.md,
|
|
|
|
|
|
minHeight: 120,
|
|
|
|
|
|
padding: 8,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridNoImageText: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
lineHeight: 20,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridVoteTag: {
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 8,
|
|
|
|
|
|
right: 8,
|
|
|
|
|
|
backgroundColor: `${colors.primary.main}CC`,
|
|
|
|
|
|
borderRadius: borderRadius.full,
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
paddingVertical: 4,
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridVoteTagText: {
|
|
|
|
|
|
color: colors.primary.contrast,
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
},
|
|
|
|
|
|
gridTitleMain: {
|
|
|
|
|
|
color: colors.text.primary,
|
|
|
|
|
|
fontSize: fontSizes.md,
|
|
|
|
|
|
lineHeight: 20,
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
paddingTop: 8,
|
|
|
|
|
|
minHeight: 44,
|
|
|
|
|
|
},
|
2026-03-25 01:29:41 +08:00
|
|
|
|
gridChannelRow: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 4,
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
paddingTop: 4,
|
|
|
|
|
|
paddingBottom: 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridChannelText: {
|
|
|
|
|
|
fontSize: fontSizes.xs,
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
2026-03-24 04:23:13 +08:00
|
|
|
|
gridFooter: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
paddingHorizontal: 8,
|
|
|
|
|
|
paddingVertical: 10,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridUserArea: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridUsername: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 6,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
gridLikeArea: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
gridLikeCount: {
|
|
|
|
|
|
color: colors.text.secondary,
|
|
|
|
|
|
fontSize: fontSizes.sm,
|
|
|
|
|
|
marginLeft: 4,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-24 03:02:54 +08:00
|
|
|
|
export default PostCard;
|