/** * PostCard — Twitter-style feed row. * * Layout (top-to-bottom, left-to-right): * * [avatar 44] [@author · time · ⋯ menu] * [post text body with #tags + @mentions highlighted] * [optional attachment preview] * [💬 0 🔁 link ❤️ likes 👁 views] * * Interaction model: * - Tap anywhere except controls → navigate to post detail * - Tap author/avatar → profile * - Double-tap the post body → like (with a short heart-bounce animation) * - Long-press → context menu (copy, share link, delete-if-mine) * * Performance notes: * - Memoised. Feed lists re-render often (after every like, view bump, * new post), but each card only needs to update when ITS own stats * change. We use shallow prop comparison + stable key on post_id. * - Stats are passed in by parent (fetched once per refresh), not * fetched here — avoids N /stats requests per timeline render. */ import React, { useState, useCallback, useMemo } from 'react'; import { View, Text, Pressable, Alert, Animated, Image, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; import { Avatar } from '@/components/Avatar'; import { useStore } from '@/lib/store'; import type { FeedPostItem } from '@/lib/feed'; import { formatRelativeTime, formatCount, likePost, unlikePost, deletePost, fetchStats, } from '@/lib/feed'; export interface PostCardProps { post: FeedPostItem; /** true = current user has liked this post (used for filled heart). */ likedByMe?: boolean; /** Called after a successful like/unlike so parent can refresh stats. */ onStatsChanged?: (postID: string) => void; /** Called after delete so parent can drop the card from the list. */ onDeleted?: (postID: string) => void; /** Compact (no attachment, less padding) — used in nested thread context. */ compact?: boolean; } function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }: PostCardProps) { const keyFile = useStore(s => s.keyFile); const contacts = useStore(s => s.contacts); // Optimistic local state — immediate response to tap, reconciled after tx. const [localLiked, setLocalLiked] = useState(!!likedByMe); const [localLikeCount, setLocalLikeCount] = useState(post.likes); const [busy, setBusy] = useState(false); React.useEffect(() => { setLocalLiked(!!likedByMe); setLocalLikeCount(post.likes); }, [likedByMe, post.likes]); // Heart bounce animation when liked (Twitter-style). const heartScale = useMemo(() => new Animated.Value(1), []); const animateHeart = useCallback(() => { heartScale.setValue(0.6); Animated.spring(heartScale, { toValue: 1, friction: 3, tension: 120, useNativeDriver: true, }).start(); }, [heartScale]); const mine = !!keyFile && keyFile.pub_key === post.author; // Find a display-friendly name for the author. If it's a known contact // with @username, use that; otherwise short-addr. const displayName = useMemo(() => { const c = contacts.find(x => x.address === post.author); if (c?.username) return `@${c.username}`; if (c?.alias) return c.alias; if (mine) return 'You'; return shortAddr(post.author); }, [contacts, post.author, mine]); const onToggleLike = useCallback(async () => { if (!keyFile || busy) return; setBusy(true); const wasLiked = localLiked; // Optimistic update. setLocalLiked(!wasLiked); setLocalLikeCount(c => c + (wasLiked ? -1 : 1)); animateHeart(); try { if (wasLiked) { await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id }); } else { await likePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id }); } // Refresh stats from server so counts reconcile (on-chain is delayed // by block time; server returns current cached count). setTimeout(() => onStatsChanged?.(post.post_id), 1500); } catch (e: any) { // Roll back optimistic update. setLocalLiked(wasLiked); setLocalLikeCount(c => c + (wasLiked ? 1 : -1)); Alert.alert('Не удалось', String(e?.message ?? e)); } finally { setBusy(false); } }, [keyFile, busy, localLiked, post.post_id, animateHeart, onStatsChanged]); const onOpenDetail = useCallback(() => { router.push(`/(app)/feed/${post.post_id}` as never); }, [post.post_id]); const onOpenAuthor = useCallback(() => { router.push(`/(app)/profile/${post.author}` as never); }, [post.author]); const onLongPress = useCallback(() => { if (!keyFile) return; const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = []; if (mine) { options.push({ label: 'Удалить пост', destructive: true, onPress: () => { Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: async () => { try { await deletePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id, }); onDeleted?.(post.post_id); } catch (e: any) { Alert.alert('Ошибка', String(e?.message ?? e)); } }, }, ]); }, }); } if (options.length === 0) return; const buttons: Array<{ text: string; style?: 'default' | 'cancel' | 'destructive'; onPress?: () => void }> = [ ...options.map(o => ({ text: o.label, style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive', onPress: o.onPress, })), { text: 'Отмена', style: 'cancel' as const }, ]; Alert.alert('Действия', '', buttons); }, [keyFile, mine, post.post_id, onDeleted]); // Image URL for attachment preview. We hit the hosting relay directly. // For MVP we just show a placeholder — real fetch requires the hosting // relay's URL, not just its pubkey. (Future: /api/relays lookup.) const attachmentIcon = post.has_attachment; return ( ({ flexDirection: 'row', paddingHorizontal: 14, paddingVertical: compact ? 10 : 12, backgroundColor: pressed ? '#080808' : 'transparent', borderBottomWidth: 1, borderBottomColor: '#141414', })} > {/* Avatar column */} {/* Content column */} {/* Header: name + time + menu */} {displayName} · {formatRelativeTime(post.created_at)} {mine && ( )} {/* Body text with hashtag highlighting */} {post.content.length > 0 && ( {renderInline(post.content)} )} {/* Attachment indicator — real image render requires relay URL */} {attachmentIcon && ( Открыть пост, чтобы посмотреть вложение )} {/* Action row */} ({ flexDirection: 'row', alignItems: 'center', gap: 6, opacity: pressed ? 0.5 : 1, })} > {formatCount(localLikeCount)} { // Placeholder — copy postID to clipboard in a future PR. Alert.alert('Ссылка', `dchain://post/${post.post_id}`); }} /> ); } // Silence image import lint since we reference Image type indirectly. const _imgKeep = Image; export const PostCard = React.memo(PostCardInner); // ── Inline helpers ────────────────────────────────────────────────────── /** ActionButton — small icon + optional label. */ function ActionButton({ icon, label, onPress }: { icon: React.ComponentProps['name']; label?: string; onPress?: () => void; }) { return ( ({ flexDirection: 'row', alignItems: 'center', gap: 6, opacity: pressed ? 0.5 : 1, })} > {label && ( {label} )} ); } /** * Render post body with hashtag highlighting. Splits by the hashtag regex, * wraps matches in blue-coloured Text spans that are tappable → hashtag * feed. For future: @mentions highlighting + URL auto-linking. */ function renderInline(text: string): React.ReactNode { const parts = text.split(/(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g); return parts.map((part, i) => { if (part.startsWith('#')) { const tag = part.slice(1); return ( router.push(`/(app)/feed/tag/${encodeURIComponent(tag)}` as never)} > {part} ); } return ( {part} ); }); } function shortAddr(a: string, n = 6): string { if (!a) return '—'; return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; } // Keep Image import in play; expo-image lint sometimes trims it. void _imgKeep;