import { useState, useRef, useCallback, useEffect } from 'react'; import { View, Text, Modal, FlatList, TextInput, Pressable, Keyboard, Platform, ActivityIndicator, Animated, PanResponder, useWindowDimensions, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 { colors } from '../lib/theme'; import type { CommunityComment } from '../stores/community'; const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂']; const SNAP_THRESHOLD = 50; type Props = { postId: string | null; visible: boolean; onClose: () => void; }; export function PostCommentsSheet({ postId, visible, onClose }: Props) { const { t } = useTranslation(); 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); // useWindowDimensions: live-tracking. Auf Android schrumpft `height` wenn die // Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt // `Dimensions.get` (statisch beim Modul-Load). const { height: SCREEN_HEIGHT } = useWindowDimensions(); const COLLAPSED_HEIGHT = SCREEN_HEIGHT * 0.65; const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.92; const MIN_HEIGHT = SCREEN_HEIGHT * 0.35; // Sheet-Höhe animiert (height-based, bottom: 0 fix → Input bleibt immer am Edge sichtbar). // Plus separater translateY für die Dismiss-Slide-Animation (native). const sheetHeight = useRef(new Animated.Value(COLLAPSED_HEIGHT)).current; const dismissY = useRef(new Animated.Value(0)).current; const currentHeight = useRef(COLLAPSED_HEIGHT); const handleClose = useCallback(() => { Keyboard.dismiss(); setText(''); setReplyTarget(null); sheetHeight.setValue(COLLAPSED_HEIGHT); dismissY.setValue(0); currentHeight.current = COLLAPSED_HEIGHT; onClose(); }, [onClose, sheetHeight, dismissY]); useEffect(() => { if (visible) { sheetHeight.setValue(COLLAPSED_HEIGHT); dismissY.setValue(0); currentHeight.current = COLLAPSED_HEIGHT; } }, [visible, sheetHeight, dismissY]); const panResponder = useRef( PanResponder.create({ // Claim Gesture sofort, kein Wartet-bis-5px onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderTerminationRequest: () => false, onPanResponderMove: (_, g) => { // Drag rauf (g.dy < 0) → height grösser. Drag runter → height kleiner. const next = currentHeight.current - g.dy; const clamped = Math.max(MIN_HEIGHT - 100, Math.min(EXPANDED_HEIGHT + 20, next)); sheetHeight.setValue(clamped); }, onPanResponderRelease: (_, g) => { const finalH = currentHeight.current - g.dy; const velocity = g.vy; // Pixel pro ms (negativ = nach oben, positiv = nach unten) // Unter MIN_HEIGHT oder schneller Flick nach unten → dismiss if (finalH < MIN_HEIGHT || velocity > 1.5) { Animated.timing(dismissY, { toValue: SCREEN_HEIGHT, duration: 200, useNativeDriver: true, }).start(() => { handleClose(); }); return; } // Schneller Flick nach oben → auf Maximum schnappen let target = finalH; if (velocity < -1.5) { target = EXPANDED_HEIGHT; } // Clamp auf gültigen Bereich, sonst bleibt's wo der User losgelassen hat const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target)); Animated.spring(sheetHeight, { toValue: clamped, useNativeDriver: false, friction: 9, tension: 70, }).start(); currentHeight.current = clamped; }, }), ).current; useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const showSub = Keyboard.addListener(showEvent, (e) => { setKeyboardHeight(e.endCoordinates.height); }); const hideSub = Keyboard.addListener(hideEvent, () => { setKeyboardHeight(0); }); return () => { showSub.remove(); hideSub.remove(); }; }, []); 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'] }); } catch { // ignore } finally { setSubmitting(false); } }, [text, postId, replyTarget, queryClient]); 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], ); // Bei offener Tastatur → automatisch expanded useEffect(() => { if (keyboardHeight > 0 && currentHeight.current !== EXPANDED_HEIGHT) { Animated.spring(sheetHeight, { toValue: EXPANDED_HEIGHT, useNativeDriver: false, friction: 9, tension: 70, }).start(); currentHeight.current = EXPANDED_HEIGHT; } }, [keyboardHeight, sheetHeight]); return ( {/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */} {/* Outer: animated height (non-native driver) */} {/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */} {/* Drag-Bar — drag-down dismisst via PanResponder */} {/* Header — auch drag-area, kein X-Button */} {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((t) => t + 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: '#e5e5e5', }} > ({ width: 40, height: 40, borderRadius: 20, backgroundColor: colors.brandOrange, alignItems: 'center', justifyContent: 'center', opacity: pressed || !text.trim() || submitting ? 0.5 : 1, })} > {submitting ? ( ) : ( )} ); } type CommentRowProps = { comment: CommunityComment; isReply?: boolean; onReply?: () => void; onLike: () => void; }; function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) { const { t } = useTranslation(); 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 ?? 'AN').slice(0, 2).toUpperCase()} {comment.authorNickname ?? t('community.anonymous_label')} {comment.content} {formatRelativeTime(comment.createdAt)} {!isReply && onReply && ( {t('community.reply')} )} {comment.likesCount > 0 && ( {comment.likesCount} )} ); }