chahinebrini 7ad523f8ba feat(rebreak-native): phase 2 sheet standardisation — SheetFieldStack + FormSheet migrations
PostCommentsSheet:
- Fix Resize-Bug: PanResponder nur auf Grabber+Header, kein onStartShouldSetPanResponderCapture
  (das stahl Touch-Events von der FlatList und brach Drag-Resize)
- Height-Limits (MAX/MIN/INITIAL) als Refs in PanResponder-Closure, damit sie nicht
  auf den ersten-Render-Stand eingefroren werden
- Keyboard-Show/-Hide animiert currentHeight korrekt ohne den Resize-Referenzpunkt
  zu verlieren
- Avatar in CommentRow: resolveAvatar() wenn authorAvatar vorhanden, Initialen-Fallback
  sonst. Bereit sobald Backend authorAvatar in Comments-Response mitliefert.
- Alle Pressable durch TouchableOpacity ersetzt

SheetFieldStack (neu):
- Progressives Multi-Input-Pattern als FormSheet-Inhalt
- Ausgefüllte Felder werden als antippbare Chips (mit Stift-Icon) nach oben verschoben
- Aktives Feld: TextInput + →/✓-Button (letztes Feld = Checkmark)
- Validate + Normalize pro Feld, Fehleranzeige unter dem Input
- suffix-Slot für Eye-Toggle etc.
- Nach letztem Feld: Keyboard.dismiss() + children (Rest des Formulars) erscheint

Migriert auf FormSheet + SheetFieldStack:
- ConnectMailSheet: Grid-View unveraendert; Form-View (email+password) via SheetFieldStack;
  Zurück/Abbrechen-Header-Buttons entfernt (Schliessen = Swipe/Backdrop)
- EditMailAccountSheet: single-password-field via SheetFieldStack; Cancel-Header-Button weg
- AddDomainSheet: domain-field via SheetFieldStack; Favicon-Preview+Warning+Checkbox+Button
  als children; Cancel-Header-Button weg
- CreateRoomSheet: name+description via SheetFieldStack; Public-Toggle+JoinMode+Buttons
  als children; Abbrechen-Button bleibt (kein Header-Button, design-OK)

useSheetKeyboardLift: geloescht (keine Aufrufer mehr nach Migration)
KeyboardAwareSheet bleibt (AddMacSheet + AddWindowsSheet nutzen es noch)

tsc --noEmit: gruen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:37:46 +02:00

559 lines
18 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import {
View,
Text,
Modal,
FlatList,
TextInput,
TouchableOpacity,
Keyboard,
Platform,
ActivityIndicator,
Animated,
Image,
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 { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
import type { CommunityComment } from '../stores/community';
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<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 MAX_HEIGHT = SCREEN_HEIGHT * 0.75;
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) {
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;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
const h = e.endCoordinates.height;
setKeyboardHeight(h);
const expanded = Math.min(currentHeight.current + h, maxHeightRef.current);
Animated.spring(sheetHeight, {
toValue: expanded,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
Animated.spring(sheetHeight, {
toValue: currentHeight.current,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [sheetHeight]);
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],
);
const dragHandlers = panResponder.panHandlers;
return (
<Modal
visible={visible}
onRequestClose={handleClose}
animationType="slide"
transparent
statusBarTranslucent
>
{/* Backdrop */}
<TouchableOpacity
activeOpacity={1}
onPress={handleClose}
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.12)' }}
/>
{/* Outer: animated height (JS driver) */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
{/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */}
<Animated.View
style={{
flex: 1,
backgroundColor: colors.bg,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Grabber-Bar — drag-area */}
<View
{...dragHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View
style={{
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: colors.border,
}}
/>
</View>
{/* Header — auch drag-area */}
<View
{...dragHandlers}
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) => (
<TouchableOpacity key={e} activeOpacity={0.7} onPress={() => setText((prev) => prev + e)}>
<Text style={{ fontSize: 22 }}>{e}</Text>
</TouchableOpacity>
))}
</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>
<TouchableOpacity activeOpacity={0.7} onPress={() => setReplyTarget(null)}>
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
</TouchableOpacity>
</View>
)}
{/* Input + Send-Button */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 10,
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]);
const avatarSize = isReply ? 24 : 32;
const avatarRadius = avatarSize / 2;
const resolvedAvatar = comment.authorAvatar
? resolveAvatar(comment.authorAvatar, comment.authorNickname ?? 'anonym')
: null;
return (
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
<View
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarRadius,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
overflow: 'hidden',
}}
>
{resolvedAvatar ? (
<Image
source={{ uri: resolvedAvatar }}
style={{ width: avatarSize, height: avatarSize, borderRadius: avatarRadius }}
/>
) : (
<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 && (
<TouchableOpacity activeOpacity={0.7} onPress={onReply}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{t('community.reply')}
</Text>
</TouchableOpacity>
)}
</View>
</View>
<TouchableOpacity
activeOpacity={0.7}
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>
)}
</TouchableOpacity>
</View>
);
}