import { useEffect, useRef } from 'react'; import { View, Text, Pressable, Modal, FlatList, Animated, Image } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useRouter, type RelativePathString } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { useNotificationStore, type AppNotification } from '../stores/notifications'; import { resolveAvatar } from '../lib/resolveAvatar'; import { HeroShieldCheck } from './HeroShieldCheck'; import { useColors } from '../lib/theme'; type Props = { visible: boolean; onClose: () => void; /** Distanz vom oberen Rand bis Dropdown anchor (Header-Höhe inkl. SafeArea) */ topOffset: number; }; export function NotificationsDropdown({ visible, onClose, topOffset }: Props) { const { t } = useTranslation(); const colors = useColors(); const router = useRouter(); const items = useNotificationStore((s) => s.items); const loaded = useNotificationStore((s) => s.loaded); const load = useNotificationStore((s) => s.load); const markRead = useNotificationStore((s) => s.markRead); const unread = useNotificationStore((s) => s.unread); const opacity = useRef(new Animated.Value(0)).current; const translateY = useRef(new Animated.Value(-8)).current; useEffect(() => { if (visible) { if (!loaded) load(); // Mark as read with delay so user sees the unread highlight briefly const tm = setTimeout(() => { if (unread > 0) markRead(); }, 600); Animated.parallel([ Animated.timing(opacity, { toValue: 1, duration: 140, useNativeDriver: true }), Animated.timing(translateY, { toValue: 0, duration: 160, useNativeDriver: true }), ]).start(); return () => clearTimeout(tm); } opacity.setValue(0); translateY.setValue(-8); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); function handleNavigate(n: AppNotification) { onClose(); if (n.type === 'domain_accepted' || n.type === 'domain_rejected') { router.push('/blocker' as RelativePathString); } else if (n.postId) { router.push(`/?postId=${n.postId}` as RelativePathString); } } return ( true} style={{ position: 'absolute', top: topOffset + 6, right: 12, backgroundColor: colors.bg, borderRadius: 18, shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 12, width: 320, maxHeight: 480, overflow: 'hidden', opacity, transform: [{ translateY }], }} > {/* Header */} {t('notifications.title')} {unread > 0 && ( markRead()} hitSlop={6}> {t('notifications.mark_all_read')} )} {items.length === 0 ? ( {t('notifications.empty_title')} {t('notifications.empty_subtitle')} ) : ( n.id} renderItem={({ item }) => ( handleNavigate(item)} t={t} /> )} /> )} ); } function notifLabel(n: AppNotification, t: (k: string, opts?: any) => string): string { switch (n.type) { case 'new_like': return `${n.actorName} ${t('notifications.liked_post')}`; case 'new_comment': return `${n.actorName} ${t('notifications.commented_post')}`; case 'domain_vote': return `${n.actorName} ${t('notifications.voted_domain')}`; case 'domain_accepted': return n.preview ? `${n.preview} ${t('notifications.domain_accepted')}` : t('notifications.domain_accepted'); case 'domain_rejected': return n.preview ? `${n.preview} ${t('notifications.domain_rejected')}` : t('notifications.domain_rejected'); case 'new_follower': return `${n.actorName} ${t('notifications.new_follower')}`; default: return `${n.actorName} ${t('notifications.generic')}`; } } function notifIcon(type: string): { icon: React.ComponentProps['name']; color: string; bg: string; } { switch (type) { case 'new_like': return { icon: 'heart', color: '#dc2626', bg: '#fee2e2' }; case 'new_comment': return { icon: 'chatbubble-ellipses', color: '#2563eb', bg: '#dbeafe' }; case 'domain_accepted': return { icon: 'shield-checkmark', color: '#16a34a', bg: '#dcfce7' }; case 'domain_rejected': return { icon: 'close-circle', color: '#dc2626', bg: '#fee2e2' }; case 'domain_vote': return { icon: 'thumbs-up', color: '#d97706', bg: '#fef3c7' }; case 'new_follower': return { icon: 'person-add', color: '#7c3aed', bg: '#ede9fe' }; default: return { icon: 'notifications', color: '#737373', bg: '#f5f5f5' }; } } function timeAgo(dateStr: string, t: (k: string, opts?: any) => string): string { const m = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000); if (m < 1) return t('notifications.just_now'); if (m < 60) return t('notifications.min_ago', { n: m }); const h = Math.floor(m / 60); if (h < 24) return t('notifications.hours_ago', { n: h }); return t('notifications.days_ago', { n: Math.floor(h / 24) }); } function NotificationRow({ notif, onPress, t, }: { notif: AppNotification; onPress: () => void; t: (k: string, opts?: any) => string; }) { const colors = useColors(); const isUnread = !notif.readAt; const { icon, color, bg } = notifIcon(notif.type); const isSocial = notif.type === 'new_like' || notif.type === 'new_comment' || notif.type === 'new_follower' || notif.type === 'domain_vote'; // System-Notifications (von ReBreak selbst) bekommen das App-Icon als Avatar const isSystem = notif.type === 'domain_accepted' || notif.type === 'domain_rejected' || (notif.actorName ?? '').toLowerCase().startsWith('rebreak'); const avatarUrl = isSocial ? resolveAvatar(notif.actorAvatar, notif.actorName) : null; return ( ({ opacity: pressed ? 0.65 : 1, })} > {/* Avatar-Logik: - Social: User-Avatar mit kleinem Type-Badge - System (ReBreak): App-Icon mit kleinem Type-Badge - Sonst: Typed Icon */} {avatarUrl ? ( // Social: Avatar mit kleinem Mini-Badge — Badge ohne weißen Ring (clean). ) : ( // System (domain_accepted/rejected/etc.) + Fallback: NUR clean Icon, // kein Avatar-Overlay mehr — vorher hatte ReBreak-App-Icon mit // Shield-Badge-Overlay den Logo verdeckt (User-Feedback 2026-05-05). {notif.type === 'domain_accepted' ? ( ) : ( )} )} {notifLabel(notif, t)} {timeAgo(notif.createdAt, t)} ); }