chahinebrini 14452b2a46 refactor(native): Pressable → TouchableOpacity sweep (style-fn swallows Android styles)
Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.

Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.

64 Dateien, typecheck sauber.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:43:10 +02:00

509 lines
17 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import {
View,
Text,
Modal,
FlatList,
TextInput,
Pressable,
TouchableOpacity,
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 { useColors } 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 colors = useColors();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(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<CommunityComment[]>({
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 (
<Modal
visible={visible}
onRequestClose={handleClose}
animationType="slide"
transparent
statusBarTranslucent
>
{/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */}
<Pressable
onPress={handleClose}
style={{
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.12)',
}}
/>
{/* Outer: animated height (non-native driver) */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
{/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */}
<Animated.View
style={{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
// Android: windowSoftInputMode=adjustResize schrumpft schon das Window
// → KEIN paddingBottom mehr (sonst doppelter Offset, Drawer schießt nach oben).
// iOS: kein adjustResize-Equivalent, padding muss hier kompensieren.
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Drag-Bar — drag-down dismisst via PanResponder */}
<View
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View
style={{
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: colors.border,
}}
/>
</View>
{/* Header — auch drag-area, kein X-Button */}
<View
{...panResponder.panHandlers}
style={{
paddingHorizontal: 20,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{t('community.comments_title')}
</Text>
</View>
{/* Comments-Liste */}
{isLoading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<FlatList
data={topLevel}
keyExtractor={(item) => item.id}
style={{ flex: 1 }}
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Text style={{ fontSize: 14, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{t('community.comments_empty')}
</Text>
</View>
}
renderItem={({ item: comment }) => (
<View>
<CommentRow
comment={comment}
onReply={() => {
setReplyTarget({ id: comment.id, nickname: comment.authorNickname });
inputRef.current?.focus();
}}
onLike={() => likeComment(comment)}
/>
{repliesFor(comment.id).map((reply) => (
<View key={reply.id} style={{ marginLeft: 44 }}>
<CommentRow comment={reply} isReply onLike={() => likeComment(reply)} />
</View>
))}
</View>
)}
/>
)}
{/* Emoji-Bar */}
<View
style={{
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 16,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: colors.border,
}}
>
{EMOJIS.map((e) => (
<Pressable key={e} onPress={() => setText((t) => t + e)}>
<Text style={{ fontSize: 22 }}>{e}</Text>
</Pressable>
))}
</View>
{/* Reply-Context */}
{replyTarget && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: colors.surface,
}}
>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('community.reply_to')}{' '}
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
</Text>
<Pressable onPress={() => setReplyTarget(null)}>
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
</Pressable>
</View>
)}
{/* Input + Send-Button */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 10,
// Bei offener Tastatur kleines Padding (kein Home-Indicator sichtbar),
// sonst Safe-Area
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
borderTopWidth: 1,
borderTopColor: colors.border,
}}
>
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
placeholder={t('community.comment_placeholder')}
placeholderTextColor={colors.textMuted}
style={{
flex: 1,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
marginRight: 8,
}}
returnKeyType="send"
onSubmitEditing={submit}
blurOnSubmit={false}
/>
<TouchableOpacity
onPress={submit}
disabled={!text.trim() || submitting}
activeOpacity={0.5}
style={{ opacity: !text.trim() || submitting ? 0.5 : 1 }}
>
<View style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
}}>
{submitting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="paper-plane" size={16} color="#fff" />
)}
</View>
</TouchableOpacity>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
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 (
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
<View
style={{
width: isReply ? 24 : 32,
height: isReply ? 24 : 32,
borderRadius: isReply ? 12 : 16,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
}}
>
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
</Text>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.text }}>
{comment.authorNickname ?? t('community.anonymous_label')}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 20,
marginTop: 2,
}}
>
{comment.content}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
<Text style={{ fontSize: 10, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{formatRelativeTime(comment.createdAt)}
</Text>
{!isReply && onReply && (
<Pressable onPress={onReply}>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{t('community.reply')}
</Text>
</Pressable>
)}
</View>
</View>
<Pressable onPress={handleLikeWithPop} style={{ alignItems: 'center', gap: 2, paddingTop: 2 }}>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={comment.userLike ? 'heart' : 'heart-outline'}
size={16}
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
/>
</Animated.View>
{comment.likesCount > 0 && (
<Text style={{ fontSize: 9, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{comment.likesCount}
</Text>
)}
</Pressable>
</View>
);
}