import { memo, useState, useCallback, useRef, useEffect } from 'react'; import { View, Text, Image, Pressable, Animated, TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'expo-router'; import i18n from '../lib/i18n'; import { apiFetch } from '../lib/api'; 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'; import { UserAvatar } from './UserAvatar'; /** * Domain-Approval-Posts werden vom Backend in 4 Sprachen parallel via Groq * generiert und als JSON-encoded `{de:'...',en:'...',fr:'...',ar:'...'}` im * `content`-Feld gespeichert. Diese Helper parsed das + pickt die aktuelle * App-Locale; fällt auf DE zurück wenn locale fehlt; gibt plain content * zurück wenn der content kein JSON ist (Legacy-Posts, User-Posts, Reposts). */ function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang: string): string { if (!raw) return ''; // Quick-Reject: wenn nicht mit '{' anfängt, ist's mit Sicherheit kein JSON. // Vermeidet JSON.parse-Overhead für 99.9% der Posts (normale Text-Posts). if (raw[0] !== '{') return raw; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const langs = ['de', 'en', 'fr', 'ar']; const hasLocaleKey = Object.keys(parsed).some((k) => langs.includes(k)); if (hasLocaleKey) { return parsed[currentLang] ?? parsed.de ?? parsed.en ?? raw; } } } catch { // not JSON, fall through } return raw; } type Props = { post: CommunityPost; onCommentPress: (postId: string) => void; }; function PostCardImpl({ post, onCommentPress }: Props) { const { t } = useTranslation(); const colors = useColors(); const queryClient = useQueryClient(); const router = useRouter(); // 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); const [localLike, setLocalLike] = useState<'like' | null>(post.userLike === 'like' ? 'like' : null); const [localCount, setLocalCount] = useState(post.likesCount); const [isLiking, setIsLiking] = useState(false); // Foreign-like count sync. When useCommunityRealtime patches the React-Query // cache (UPDATE on community_posts → likes_count changed), the prop updates // but localCount is seeded once via useState and stays stale. Sync it. // // The wasLikingRef trick: when isLiking goes true → false (own action just // settled), the prop hasn't been patched yet by the realtime broadcast (it // arrives ~100-300ms later as Supabase relays our own DB update back to us). // We skip the FIRST useEffect run after the transition so we don't overwrite // the value handleLike just set with the still-stale prop. The next prop // change (= cache patch arriving) re-fires the effect and syncs correctly. // For pure foreign likes (no own action in flight) the first run goes // through directly. const wasLikingRef = useRef(false); useEffect(() => { if (isLiking) { wasLikingRef.current = true; return; } if (wasLikingRef.current) { wasLikingRef.current = false; return; } setLocalCount(post.likesCount); }, [post.likesCount, isLiking]); // 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 rawContent = post.repostOf ? post.repostOf.content : post.content; // i18n-aware content. Drei Pfade in Prioritäts-Reihenfolge: // 1. post.i18nKey gesetzt → t(`lyra_posts.${id}`) (Catalog-Lyra-Posts) // 2. content ist JSON-encoded mit Locale-Keys → pick current locale // (Domain-Approval-Posts, Server-generiert 4x parallel) // 3. plain content (Legacy-Posts, Comments, User-Posts) → unverändert const i18nKey = post.repostOf ? undefined : post.i18nKey; const currentLang = (i18n.language ?? 'de').slice(0, 2); const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : resolveLocalizedJsonContent(rawContent, currentLang); 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'; const avatarUserId = !post.isAnonymous && !isLyraPost ? displayAuthor.id ?? null : null; const avatarId = !post.isAnonymous && !isLyraPost ? displayAuthor.avatar ?? null : null; // 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(); const { newLike, newCount } = applyOptimisticLike(post.id, localLike, localCount); setLocalLike(newLike); setLocalCount(newCount); 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' }, }); setLocalCount(res.likesCount); setLocalLike(res.userLike === 'like' ? 'like' : null); clearOptimisticLike(post.id); // KEIN queryClient.invalidateQueries — würde die komplette Liste neu laden, // PostCard remounted, Heart-Pop-Animation abgebrochen. Local-State reicht. } catch { revertOptimisticLike(post.id); setLocalLike(post.userLike === 'like' ? 'like' : null); setLocalCount(post.likesCount); } finally { setIsLiking(false); } }, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]); return ( {/* Repost header */} {post.repostOf && ( {post.author.nickname} {t('community.reposted_suffix')} )} {/* Author + Meta */} router.push(`/profile/${displayAuthor.id}`) : undefined} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, flex: 1 }} > {isLyraPost ? ( ) : ( )} {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' && ( {localCount > 0 && ( {localCount} )} 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 ''; } }