/** * 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 { getNodeUrl } from '@/lib/api'; import type { FeedPostItem } from '@/lib/feed'; import { formatRelativeTime, formatCount, likePost, unlikePost, deletePost, } from '@/lib/feed'; import { ShareSheet } from '@/components/feed/ShareSheet'; 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); const [shareOpen, setShareOpen] = 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]); // Attachment preview URL β€” native Image can stream straight from the // hosting relay's /feed/post/{id}/attachment endpoint. `getNodeUrl()` // returns the node the client is connected to; for cross-relay posts // that's actually the hosting relay once /api/relays resolution lands // (Phase D). For now we assume same-node. const attachmentURL = post.has_attachment ? `${getNodeUrl()}/feed/post/${post.post_id}/attachment` : null; // Body content truncation: // - In the timeline (compact=undefined/false) cap at 5 lines. If the // text is longer the rest is hidden behind "…" β€” tapping the card // opens the detail view where the full body is shown. // - In post detail (compact=true) show everything. const bodyLines = compact ? undefined : 5; return ( <> {/* Outer container is a plain View so layout styles (padding, row direction) are static and always applied. Pressable's dynamic style-function has been observed to drop properties between renders on some RN versions β€” we hit that with the FAB, so we're not relying on it here either. Tap handling lives on the content-column Pressable (covers ~90% of the card area) plus a separate Pressable around the avatar. */} {/* Card = vertical stack: [HEADER row with avatar+name+time] / [FULL-WIDTH content column with body/image/actions]. Putting content under the header (rather than in a column next to the avatar) means body text and attachments occupy the full card width β€” no risk of the text running next to the avatar and clipping off the right edge. */} {/* ── HEADER ROW: [avatar] [name Β· time] [menu] ──────────────── */} {/* Name + time take all remaining horizontal space in the header, with the name truncating (numberOfLines:1 + flexShrink:1) and the "Β· {/* ── CONTENT (body, attachment, actions) β€” full card width ──── */} ({ marginTop: 8, overflow: 'hidden', opacity: pressed ? 0.85 : 1, })} > {/* Body text with hashtag highlighting. Full card width now (we moved it out of the avatar-sibling column) β€” no special width tricks needed, normal wrapping just works. */} {post.content.length > 0 && ( {renderInline(post.content)} )} {/* Attachment preview. Timeline (compact=false): aspect-ratio capped at 4:5 so a portrait photo doesn't occupy the whole screen β€” extra height is cropped via resizeMode="cover", full image shows in detail. Detail (compact=true): contain with no aspect cap β†’ original proportions preserved. */} {attachmentURL && ( )} {/* Action row β€” 3 buttons (Twitter-style). Comments button intentionally omitted: v2.0.0 doesn't implement replies and a dead button with "0" adds noise. Heart + views + share distribute across the row; share pins to the right edge. */} ({ flexDirection: 'row', alignItems: 'center', gap: 6, opacity: pressed ? 0.5 : 1, })} > {formatCount(localLikeCount)} setShareOpen(true)} /> setShareOpen(false)} /> ); } export const PostCard = React.memo(PostCardInner); /** * PostSeparator β€” visible divider line between post cards. Exported so * every feed surface (timeline, author, hashtag, post detail) can pass * it as ItemSeparatorComponent and get identical spacing / colour. * * Layout: 12px blank space β†’ 1px grey line β†’ 12px blank space. The * blank space on each side makes the line "float" between posts rather * than hugging the edge of the card β€” gives clear visual separation * without needing big card padding everywhere. * * Colour #2a2a2a is the minimum grey that reads on OLED black under * mobile-bright-mode gamma; darker and the seam vanishes. */ export function PostSeparator() { return ( ); } // ── 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)}`; }