import { memo, useState, useCallback, useRef, useEffect } from 'react'; import { View, Text, Pressable, Image, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { apiFetch } from '../lib/api'; import { resolveAvatar } from '../lib/resolveAvatar'; import { formatRelativeTime } from '../lib/formatTime'; import { useCommunityStore, type CommunityPost } from '../stores/community'; import { RiveAvatar } from './RiveAvatar'; import { HeroShieldCheck } from './HeroShieldCheck'; import { useColors } from '../lib/theme'; type Props = { post: CommunityPost; onCommentPress: (postId: string) => void; }; function PostCardImpl({ post, onCommentPress }: Props) { const { t } = useTranslation(); const colors = useColors(); const queryClient = useQueryClient(); // Granular selectors — subscribing to the whole store would re-render every // PostCard whenever any user likes any post (optimisticLikes mutates). const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike); const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike); const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike); // Display state is DERIVED from props + the store-level optimistic delta. // No useState mirror — that's exactly what created the foreign-likes-don't- // update bug (seed-once from props) and the post-action flicker (useEffect // re-sync racing with the React-Query cache patch). Realtime patches the // cache → prop changes → we re-render automatically. Optimistic UI lives // for the ms between tap and API-response in the community-store's // `optimisticLikes` map (delta + userLike). const optimistic = useCommunityStore((s) => s.optimisticLikes[post.id]); const displayedLike: 'like' | null = optimistic ? optimistic.userLike : post.userLike === 'like' ? 'like' : null; const displayedCount = (post.likesCount ?? 0) + (optimistic?.delta ?? 0); const [isLiking, setIsLiking] = useState(false); // Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back const heartScale = useRef(new Animated.Value(1)).current; const triggerHeartPop = useCallback(() => { heartScale.setValue(1); Animated.sequence([ Animated.timing(heartScale, { toValue: 1.4, duration: 120, useNativeDriver: true, }), Animated.spring(heartScale, { toValue: 1, friction: 4, tension: 80, useNativeDriver: true, }), ]).start(); }, [heartScale]); const displayAuthor = post.repostOf ? post.repostOf.author : post.author; const displayContent = post.repostOf ? post.repostOf.content : post.content; const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl; // Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}. // Fallback während loading = 1.78 (16:9). Clamp 0.6..1.78 verhindert, // dass hyper-portrait-Bilder (9:21) den ganzen Screen einnehmen. const [imageAspectRatio, setImageAspectRatio] = useState(null); // Reset bei post-change (FlatList-Recycling). useEffect(() => { setImageAspectRatio(null); }, [displayImage]); const authorLabel = post.isAnonymous || !displayAuthor.id ? t('community.anonymous_label') : displayAuthor.nickname; // Lyra bot posts use the RiveAvatar (sm = 40px circle). All other bots and // regular users use the image/initials fallback path. const isLyraPost = post.isBot && post.botType === 'lyra'; // Avatar: only render Image if author has avatar id; resolveAvatar returns the URL. // On image-load error or missing avatar id → initials fallback. const hasAvatar = !!displayAuthor.avatar && !post.isAnonymous && !isLyraPost; const avatarUrl = hasAvatar ? resolveAvatar(displayAuthor.avatar, displayAuthor.nickname) : ''; const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); // Reset error-state when post (or its avatar) changes — list-virtualization may reuse component. useEffect(() => { setAvatarLoadFailed(false); }, [avatarUrl]); const showAvatarImage = hasAvatar && !avatarLoadFailed; const avatarInitials = ( authorLabel.charAt(0) + (authorLabel.charAt(1) ?? '') ).toUpperCase() || '?'; // domain_approved: extract domain name from Google favicon URL stored in imageUrl const approvedDomain = (() => { if (post.category !== 'domain_approved' || !displayImage) return null; try { return new URL(displayImage).searchParams.get('domain'); } catch { return null; } })(); // domain_vote vote action — delegate up via apiFetch, no local store mutation needed // (realtime hook invalidates query on submission UPDATE) const [voting, setVoting] = useState(false); const [localVote, setLocalVote] = useState<'yes' | 'no' | null>(post.userVote ?? null); const [localYes, setLocalYes] = useState(post.submission?.yesVotes ?? 0); const [localNo, setLocalNo] = useState(post.submission?.noVotes ?? 0); useEffect(() => { setLocalVote(post.userVote ?? null); setLocalYes(post.submission?.yesVotes ?? 0); setLocalNo(post.submission?.noVotes ?? 0); }, [post.userVote, post.submission?.yesVotes, post.submission?.noVotes]); const handleVote = useCallback(async (vote: 'yes' | 'no') => { if (voting || !post.submission?.id || localVote) return; setVoting(true); setLocalVote(vote); if (vote === 'yes') setLocalYes((n) => n + 1); else setLocalNo((n) => n + 1); try { const res = await apiFetch<{ yesVotes: number; noVotes: number; movedToReview: boolean }>( `/api/domain-submissions/${post.submission.id}/vote`, { method: 'POST', body: { vote } }, ); setLocalYes(res.yesVotes); setLocalNo(res.noVotes); queryClient.invalidateQueries({ queryKey: ['community-posts'] }); } catch { setLocalVote(null); if (vote === 'yes') setLocalYes((n) => Math.max(0, n - 1)); else setLocalNo((n) => Math.max(0, n - 1)); } finally { setVoting(false); } }, [voting, localVote, post.submission?.id, queryClient]); const authorDescription = (() => { if (post.isBot) return post.botType === 'rebreak' ? t('community.bot_admin') : t('community.bot_ai'); if (post.isAnonymous || !displayAuthor.id) return undefined; const plan = displayAuthor.plan; if (plan === 'legend') return t('community.tier_legend'); if (plan === 'pro') return t('community.tier_pro'); return t('community.tier_starter'); })(); const handleLike = useCallback(async () => { if (isLiking) return; triggerHeartPop(); applyOptimisticLike(post.id, displayedLike, displayedCount); setIsLiking(true); try { const res = await apiFetch<{ likesCount: number; dislikesCount: number; userLike: 'like' | 'dislike' | null; }>('/api/community/like', { method: 'POST', body: { postId: post.id, type: 'like' }, }); // Patch the React-Query cache synchronously with server truth so the // prop reflects it BEFORE we clear the optimistic delta. Without this, // there'd be a millisecond window between clearOptimistic (delta → 0) // and the Realtime broadcast patching the cache — during which the // displayed count would briefly snap back to the old prop value. queryClient.setQueriesData( { queryKey: ['community-posts'] }, (data) => Array.isArray(data) ? data.map((p) => p.id === post.id ? { ...p, likesCount: res.likesCount, dislikesCount: res.dislikesCount, userLike: res.userLike } : p, ) : data, ); clearOptimisticLike(post.id); } catch { revertOptimisticLike(post.id); } finally { setIsLiking(false); } }, [isLiking, displayedLike, displayedCount, post.id, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]); return ( {/* Repost header */} {post.repostOf && ( {post.author.nickname} {t('community.reposted_suffix')} )} {/* Author + Meta */} {isLyraPost ? ( // Lyra bot posts use the animated Rive avatar at sm (40px). // The RiveAvatar sm-variant has no border/shadow by design — fits tight in list. ) : showAvatarImage ? ( setAvatarLoadFailed(true)} className="w-10 h-10 rounded-full bg-neutral-100" /> ) : ( {avatarInitials} )} {authorLabel} {authorDescription !== undefined && ( {authorDescription} )} {formatRelativeTime(post.createdAt)} {/* Content — hidden for domain_vote (replaced by poll below) */} {!!displayContent && post.category !== 'domain_vote' && ( {displayContent} )} {/* domain_approved: favicon + domain name + shield badge */} {post.category === 'domain_approved' && !!approvedDomain && ( {approvedDomain} {t('community.domain_added_to_blocklist')} )} {/* domain_vote: poll card with domain banner + yes/no bars + vote buttons */} {post.category === 'domain_vote' && !!post.submission && ( )} {/* Image — respektiert echtes Aspect-Ratio (portrait/square/landscape), clamped 0.6..1.78 damit 9:21-Storys nicht den ganzen Screen einnehmen. */} {!!displayImage && post.category !== 'domain_approved' && post.category !== 'domain_vote' && ( { const { width, height } = e.nativeEvent.source; if (width && height) { const ratio = width / height; setImageAspectRatio(Math.max(0.6, Math.min(1.78, ratio))); } }} className="w-full rounded-xl mt-3" style={{ aspectRatio: imageAspectRatio ?? 1.78 }} resizeMode="cover" /> )} {/* Actions: Like, Comment — not shown for domain_vote */} {/* HitSlop +12pt rundum → effektiver Touch-Bereich ~44pt (HIG-Min). */} {/* Vorher: Tap-Area = nur Icon-Größe (~21pt) → User-Feedback "reagiert nicht beim 1. Klick". */} {post.category !== 'domain_vote' && ( {displayedCount > 0 && ( {displayedCount} )} onCommentPress(post.id)} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }} className="flex-row items-center gap-1.5" > {post.commentsCount > 0 && ( {post.commentsCount} )} )} ); } // React.memo with a shallow compare on the fields that actually drive visible // content. Without this, every realtime patch (likes/comments/domain-vote) // re-mapping the posts array would re-render every visible PostCard, even // though only one item changed. onCommentPress is expected stable from parent. export const PostCard = memo(PostCardImpl, (prev, next) => { if (prev.onCommentPress !== next.onCommentPress) return false; const a = prev.post; const b = next.post; if (a === b) return true; if (a.id !== b.id) return false; if (a.likesCount !== b.likesCount) return false; if (a.dislikesCount !== b.dislikesCount) return false; if (a.commentsCount !== b.commentsCount) return false; if (a.repostsCount !== b.repostsCount) return false; if (a.userLike !== b.userLike) return false; if (a.userVote !== b.userVote) return false; if (a.content !== b.content) return false; if (a.imageUrl !== b.imageUrl) return false; if (a.category !== b.category) return false; if (a.isAnonymous !== b.isAnonymous) return false; if (a.challengeStatus !== b.challengeStatus) return false; if (a.isLive !== b.isLive) return false; // submission shallow compare on the fields the card reads const sa = a.submission; const sb = b.submission; if (!!sa !== !!sb) return false; if (sa && sb) { if (sa.id !== sb.id) return false; if (sa.status !== sb.status) return false; if (sa.yesVotes !== sb.yesVotes) return false; if (sa.noVotes !== sb.noVotes) return false; if (sa.reviewedAt !== sb.reviewedAt) return false; } // author identity (anonymous toggling, avatar swap) if (a.author.id !== b.author.id) return false; if (a.author.avatar !== b.author.avatar) return false; if (a.author.nickname !== b.author.nickname) return false; if (a.author.plan !== b.author.plan) return false; // repostOf identity const ra = a.repostOf; const rb = b.repostOf; if (!!ra !== !!rb) return false; if (ra && rb) { if (ra.content !== rb.content) return false; if (ra.imageUrl !== rb.imageUrl) return false; if (ra.author.id !== rb.author.id) return false; if (ra.author.nickname !== rb.author.nickname) return false; if (ra.author.avatar !== rb.author.avatar) return false; } return true; }); // ── Domain Favicon ───────────────────────────────────────────────────────── // Google S2 favicon-API as in Nuxt PostCard — on error: letter-avatar fallback. type DomainFaviconProps = { domain: string; size: number }; function DomainFavicon({ domain, size }: DomainFaviconProps) { const [failed, setFailed] = useState(false); const uri = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; const letter = domain.charAt(0).toUpperCase(); if (failed) { return ( {letter} ); } return ( setFailed(true)} /> ); } // ── Domain Vote Poll Card ─────────────────────────────────────────────────── type Submission = NonNullable; type DomainVoteCardProps = { submission: Submission; localYes: number; localNo: number; localVote: 'yes' | 'no' | null; voting: boolean; isOwnPost: boolean; onVote: (v: 'yes' | 'no') => void; // TFunction from react-i18next has a complex overload signature; using // a simple callable type avoids generics noise here. t: (key: string) => string; }; function DomainVoteCard({ submission, localYes, localNo, localVote, voting, isOwnPost, onVote, t, }: DomainVoteCardProps) { const total = localYes + localNo; const yesWidth = total === 0 ? 0 : Math.round((localYes / 10) * 100); // No-bar is relative to yes + no total to mirror Nuxt logic const noWidth = total === 0 ? 0 : Math.round((localNo / Math.max(total, localYes)) * 100); const isPending = submission.status === 'pending' || submission.status === 'in_review'; const isApproved = submission.status === 'approved'; const statusLabel = (() => { if (isApproved) return submission.reviewedAt ? `Approved ${formatApprovedDate(submission.reviewedAt)}` : 'Global'; if (submission.status === 'rejected') return t('community.vote_rejected'); if (submission.status === 'in_review') return t('community.vote_in_review'); return `${localYes} / 10`; })(); return ( {/* Header: label + status badge */} {t('community.domain_proposal_label')} {statusLabel} {/* Domain card */} {submission.domain} {isApproved ? t('community.domain_added') : t('community.domain_proposed')} {/* Yes bar */} {t('community.vote_yes')} {localYes} / 10 {/* No bar */} {t('community.vote_no')} {localNo} {/* Vote buttons — only for pending + not own post + not already voted */} {isPending && !isOwnPost && !localVote && ( onVote('yes')} disabled={voting} className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-rebreak-500" style={{ opacity: voting ? 0.5 : 1 }} > {t('community.vote_yes')} onVote('no')} disabled={voting} className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-neutral-300" style={{ opacity: voting ? 0.5 : 1 }} > {t('community.vote_no')} )} {/* Already voted indicator */} {isPending && !isOwnPost && !!localVote && ( {t('community.voted_thanks')} )} {/* Own post indicator */} {isPending && isOwnPost && ( {t('community.domain_vote_own')} )} ); } function formatApprovedDate(dateStr: string): string { try { const d = new Date(dateStr); return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return ''; } }