chahinebrini 2e409efaf0 feat(onboarding/android + backend/lyra-i18n): platform-dispatch + post-catalog scaffold
Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
  → wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
  cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*

Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
  (NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
  default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten

Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 23:48:25 +02:00

588 lines
24 KiB
TypeScript

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);
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: wenn i18nKey gesetzt → übersetzten Text nehmen,
// sonst rawContent (Legacy-Verhalten unverändert).
const i18nKey = post.repostOf ? undefined : post.i18nKey;
const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : rawContent;
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<number | null>(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();
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 (
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 12, marginBottom: 12 }}>
{/* Repost header */}
{post.repostOf && (
<View className="flex-row items-center gap-1.5 mb-3">
<Ionicons name="repeat" size={14} color="#737373" />
<Text className="text-xs text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>
{post.author.nickname} {t('community.reposted_suffix')}
</Text>
</View>
)}
{/* Author + Meta */}
<View className="flex-row items-start justify-between mb-2">
<View className="flex-row items-center gap-2.5 flex-1">
{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.
<RiveAvatar emotion="idle" size="sm" />
) : showAvatarImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setAvatarLoadFailed(true)}
className="w-10 h-10 rounded-full bg-neutral-100"
/>
) : (
<View className="w-10 h-10 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>
{avatarInitials}
</Text>
</View>
)}
<View className="flex-1 min-w-0">
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }} numberOfLines={1}>
{authorLabel}
</Text>
{authorDescription !== undefined && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
)}
</View>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', flexShrink: 0, marginLeft: 8, marginTop: 2 }}>
{formatRelativeTime(post.createdAt)}
</Text>
</View>
{/* Content — hidden for domain_vote (replaced by poll below) */}
{!!displayContent && post.category !== 'domain_vote' && (
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
{displayContent}
</Text>
)}
{/* domain_approved: favicon + domain name + shield badge */}
{post.category === 'domain_approved' && !!approvedDomain && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface, paddingHorizontal: 12, paddingVertical: 8 }}>
<DomainFavicon domain={approvedDomain} size={24} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, color: colors.text, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
{approvedDomain}
</Text>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_added_to_blocklist')}
</Text>
</View>
<HeroShieldCheck size={18} color="#22c55e" />
</View>
)}
{/* domain_vote: poll card with domain banner + yes/no bars + vote buttons */}
{post.category === 'domain_vote' && !!post.submission && (
<DomainVoteCard
submission={post.submission}
localYes={localYes}
localNo={localNo}
localVote={localVote}
voting={voting}
isOwnPost={post.author.id === null}
onVote={handleVote}
t={t}
/>
)}
{/* 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' && (
<Image
source={{ uri: displayImage }}
onLoad={(e) => {
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' && (
<View className="flex-row items-center gap-6 mt-3 py-1">
<Pressable
onPress={handleLike}
disabled={isLiking}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
android_ripple={{ color: 'rgba(220,38,38,0.12)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5"
>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={localLike === 'like' ? 'heart' : 'heart-outline'}
size={20}
color={localLike === 'like' ? '#dc2626' : '#737373'}
/>
</Animated.View>
{localCount > 0 && (
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{localCount}</Text>
)}
</Pressable>
<Pressable
onPress={() => 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"
>
<Ionicons name="chatbubble-outline" size={19} color="#737373" />
{post.commentsCount > 0 && (
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{post.commentsCount}</Text>
)}
</Pressable>
</View>
)}
</View>
);
}
// 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 (
<View
style={{ width: size, height: size, borderRadius: 6, backgroundColor: '#e5e7eb' }}
className="items-center justify-center"
>
<Text style={{ fontSize: size * 0.5, color: '#6b7280', fontFamily: 'Nunito_700Bold' }}>
{letter}
</Text>
</View>
);
}
return (
<Image
source={{ uri }}
style={{ width: size, height: size, borderRadius: 6 }}
onError={() => setFailed(true)}
/>
);
}
// ── Domain Vote Poll Card ───────────────────────────────────────────────────
type Submission = NonNullable<CommunityPost['submission']>;
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 (
<View className="mt-1 gap-2.5">
{/* Header: label + status badge */}
<View className="flex-row items-center justify-between gap-2">
<View className="flex-row items-center gap-1.5">
<Ionicons name="shield-checkmark-outline" size={14} color="#737373" />
<Text className="text-xs text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_proposal_label')}
</Text>
</View>
<View
className={`px-2 py-0.5 rounded-full ${isApproved ? 'bg-green-100' : 'bg-neutral-100'}`}
>
<Text
className={`text-[10px] ${isApproved ? 'text-green-700' : 'text-neutral-500'}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
>
{statusLabel}
</Text>
</View>
</View>
{/* Domain card */}
<View className="flex-row items-center gap-3 rounded-xl px-3 py-2.5 border border-neutral-200 bg-neutral-50">
<DomainFavicon domain={submission.domain} size={28} />
<View className="flex-1 min-w-0">
<Text className="text-sm font-semibold text-neutral-900 truncate" style={{ fontFamily: 'Nunito_700Bold' }}>
{submission.domain}
</Text>
<Text className="text-[10px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{isApproved ? t('community.domain_added') : t('community.domain_proposed')}
</Text>
</View>
<Ionicons
name={isApproved ? 'shield-checkmark' : 'shield-half-outline'}
size={20}
color="#a3a3a3"
/>
</View>
{/* Yes bar */}
<View>
<View className="flex-row items-center justify-between mb-1">
<View className="flex-row items-center gap-1">
<Ionicons name="thumbs-up-outline" size={12} color="#525252" />
<Text className="text-[11px] text-neutral-700" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_yes')}
</Text>
</View>
<Text className="text-[11px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{localYes} / 10
</Text>
</View>
<View className="h-1.5 bg-neutral-100 rounded-full overflow-hidden">
<View className="h-full bg-rebreak-500 rounded-full" style={{ width: `${yesWidth}%` }} />
</View>
</View>
{/* No bar */}
<View>
<View className="flex-row items-center justify-between mb-1">
<View className="flex-row items-center gap-1">
<Ionicons name="thumbs-down-outline" size={12} color="#525252" />
<Text className="text-[11px] text-neutral-700" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_no')}
</Text>
</View>
<Text className="text-[11px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{localNo}
</Text>
</View>
<View className="h-1.5 bg-neutral-100 rounded-full overflow-hidden">
<View className="h-full bg-red-400 rounded-full" style={{ width: `${noWidth}%` }} />
</View>
</View>
{/* Vote buttons — only for pending + not own post + not already voted */}
{isPending && !isOwnPost && !localVote && (
<View className="flex-row gap-2 pt-0.5">
<Pressable
onPress={() => 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 }}
>
<Ionicons name="thumbs-up" size={14} color="#f97316" />
<Text className="text-sm text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_yes')}
</Text>
</Pressable>
<Pressable
onPress={() => 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 }}
>
<Ionicons name="thumbs-down" size={14} color="#737373" />
<Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_no')}
</Text>
</Pressable>
</View>
)}
{/* Already voted indicator */}
{isPending && !isOwnPost && !!localVote && (
<Text className="text-[11px] text-center text-neutral-400 pt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.voted_thanks')}
</Text>
)}
{/* Own post indicator */}
{isPending && isOwnPost && (
<Text className="text-[11px] text-center text-neutral-400 pt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_vote_own')}
</Text>
)}
</View>
);
}
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 ''; }
}