import { useState, useRef, useCallback, useEffect } from 'react'; import { View, Text, Modal, FlatList, TextInput, TouchableOpacity, Keyboard, ActivityIndicator, Animated, Dimensions, PanResponder, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useKeyboardHandler } from 'react-native-keyboard-controller'; import { runOnJS } from 'react-native-reanimated'; import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { apiFetch } from '../lib/api'; import { formatRelativeTime } from '../lib/formatTime'; import { useColors } from '../lib/theme'; import type { CommunityComment } from '../stores/community'; import { UserAvatar } from './UserAvatar'; const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂']; type Props = { postId: string | null; visible: boolean; onClose: () => void; }; export function PostCommentsSheet({ postId, visible, onClose }: Props) { const { t } = useTranslation(); const colors = useColors(); const insets = useSafeAreaInsets(); const queryClient = useQueryClient(); const inputRef = useRef(null); const [text, setText] = useState(''); const [submitting, setSubmitting] = useState(false); const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null); const [keyboardHeight, setKeyboardHeight] = useState(0); // Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert // Keyboard-Resize. MAX bis unter App-Nav-Header (~56dp) damit User per Drag // bis ganz oben ziehen kann (User-Feedback: "wie alle andere sheets"). const SCREEN_HEIGHT = Dimensions.get('screen').height; const MAX_HEIGHT = Math.max(300, SCREEN_HEIGHT - insets.top - 56); const MIN_HEIGHT = SCREEN_HEIGHT * 0.35; const INITIAL_HEIGHT = SCREEN_HEIGHT * 0.65; // Sheet-Höhe animiert (height-based, bottom: 0 fix). // Separater translateY für die Dismiss-Slide-Animation (native driver). const sheetHeight = useRef(new Animated.Value(INITIAL_HEIGHT)).current; const dismissY = useRef(new Animated.Value(0)).current; const currentHeight = useRef(INITIAL_HEIGHT); // PanResponder braucht stabile Refs auf die berechneten Limits, // weil er nur einmal erstellt wird (useRef-Semantik). const maxHeightRef = useRef(MAX_HEIGHT); const minHeightRef = useRef(MIN_HEIGHT); const screenHeightRef = useRef(SCREEN_HEIGHT); useEffect(() => { maxHeightRef.current = MAX_HEIGHT; minHeightRef.current = MIN_HEIGHT; screenHeightRef.current = SCREEN_HEIGHT; }, [MAX_HEIGHT, MIN_HEIGHT, SCREEN_HEIGHT]); const handleClose = useCallback(() => { Keyboard.dismiss(); setText(''); setReplyTarget(null); sheetHeight.setValue(INITIAL_HEIGHT); dismissY.setValue(0); currentHeight.current = INITIAL_HEIGHT; onClose(); }, [onClose, sheetHeight, dismissY, INITIAL_HEIGHT]); useEffect(() => { if (visible) { // State-Reset bei Re-Open. Wichtig: keyboardHeight zurücksetzen, weil // applyKbdHeight wegen `if (!visible) return` das `h=0`-Hide-Event nicht // verarbeitet hat → sonst öffnet Sheet beim 2. Mal mit altem paddingBottom. setKeyboardHeight(0); sheetHeight.setValue(INITIAL_HEIGHT); dismissY.setValue(0); currentHeight.current = INITIAL_HEIGHT; } }, [visible, sheetHeight, dismissY, INITIAL_HEIGHT]); // PanResponder NUR für Grabber-Bar + Header — nicht für den Content-Bereich. // onStartShouldSetPanResponderCapture würde FlatList-Scroll brechen. const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dy) > 4, onPanResponderTerminationRequest: () => false, onPanResponderMove: (_, g) => { const next = currentHeight.current - g.dy; const clamped = Math.max( minHeightRef.current - 80, Math.min(maxHeightRef.current + 16, next), ); sheetHeight.setValue(clamped); }, onPanResponderRelease: (_, g) => { const finalH = currentHeight.current - g.dy; const v = g.vy; if (finalH < minHeightRef.current || v > 1.5) { Animated.timing(dismissY, { toValue: screenHeightRef.current, duration: 200, useNativeDriver: true, }).start(() => { Keyboard.dismiss(); setText(''); setReplyTarget(null); sheetHeight.setValue(INITIAL_HEIGHT); dismissY.setValue(0); currentHeight.current = INITIAL_HEIGHT; onClose(); }); return; } let target = finalH; if (v < -1.5) target = maxHeightRef.current; const clamped = Math.max(minHeightRef.current, Math.min(maxHeightRef.current, target)); Animated.spring(sheetHeight, { toValue: clamped, useNativeDriver: false, friction: 9, tension: 70, }).start(); currentHeight.current = clamped; }, }), ).current; // keyboard-controller: Modal-aware Frame-Werte für iOS+Android (siehe Memory // feedback_use_keyboard_controller). Manuelles Keyboard.addListener war auf // Android im Modal unzuverlässig (paddingBottom=0 → Input hinter Tastatur). const applyKbdHeight = (h: number) => { if (!visible) return; setKeyboardHeight(h); const target = h > 0 ? Math.min(currentHeight.current + h, maxHeightRef.current) : currentHeight.current; Animated.spring(sheetHeight, { toValue: target, useNativeDriver: false, friction: 9, tension: 70, }).start(); }; useKeyboardHandler({ onStart: (e) => { 'worklet'; runOnJS(applyKbdHeight)(e.height); }, onEnd: (e) => { 'worklet'; runOnJS(applyKbdHeight)(e.height); }, }); const { data: comments = [], isLoading } = useQuery({ queryKey: ['post-comments', postId], queryFn: () => apiFetch(`/api/community/${postId}/comments`), enabled: !!postId && visible, staleTime: 30_000, }); const topLevel = comments.filter((c) => !c.parentCommentId); const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id); const submit = useCallback(async () => { if (!text.trim() || !postId) return; setSubmitting(true); try { await apiFetch('/api/community/comment', { method: 'POST', body: { postId, content: text.trim(), ...(replyTarget ? { parentCommentId: replyTarget.id } : {}), }, }); setText(''); setReplyTarget(null); queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); queryClient.invalidateQueries({ queryKey: ['community-posts'] }); // Sheet schließen nach erfolgreicher Comment-Abgabe (User-Feedback). handleClose(); } catch { // ignore } finally { setSubmitting(false); } }, [text, postId, replyTarget, queryClient, handleClose]); const likeComment = useCallback( async (comment: CommunityComment) => { try { await apiFetch('/api/community/comment-like', { method: 'POST', body: { commentId: comment.id }, }); queryClient.invalidateQueries({ queryKey: ['post-comments', postId] }); } catch { // ignore } }, [postId, queryClient], ); const dragHandlers = panResponder.panHandlers; return ( {/* Backdrop */} {/* Outer: animated height (JS driver) */} {/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */} {/* Grabber-Bar — drag-area */} {/* Header — auch drag-area */} {t('community.comments_title')} {/* Comments-Liste */} {isLoading ? ( ) : ( item.id} style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }} keyboardShouldPersistTaps="handled" ListEmptyComponent={ {t('community.comments_empty')} } renderItem={({ item: comment }) => ( { setReplyTarget({ id: comment.id, nickname: comment.authorNickname }); inputRef.current?.focus(); }} onLike={() => likeComment(comment)} /> {repliesFor(comment.id).map((reply) => ( likeComment(reply)} /> ))} )} /> )} {/* Emoji-Bar */} {EMOJIS.map((e) => ( setText((prev) => prev + e)}> {e} ))} {/* Reply-Context */} {replyTarget && ( {t('community.reply_to')}{' '} @{replyTarget.nickname} setReplyTarget(null)}> )} {/* Input + Send-Button */} 0 ? 8 : Math.max(12, insets.bottom), borderTopWidth: 1, borderTopColor: colors.border, }} > {submitting ? ( ) : ( )} ); } type CommentRowProps = { comment: CommunityComment; isReply?: boolean; onReply?: () => void; onLike: () => void; }; function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) { const { t } = useTranslation(); const colors = useColors(); const heartScale = useRef(new Animated.Value(1)).current; const handleLikeWithPop = 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(); onLike(); }, [heartScale, onLike]); return ( {comment.authorNickname ?? t('community.anonymous_label')} {comment.content} {formatRelativeTime(comment.createdAt)} {!isReply && onReply && ( {t('community.reply')} )} {comment.likesCount > 0 && ( {comment.likesCount} )} ); }