- MessageActionMenu: an der Bubble verankert (measureInWindow) statt zentriert, Blur-Backdrop, Emoji-Leiste oben (fremd) + Aktions-Liste unten (fremd: Antworten/Kopieren, eigen: Kopieren/Löschen) - ChatBubble: Long-Press → measure + Menu, Reaction-Pills unter Bubble, Tombstone "Nachricht gelöscht"; ersetzt @expo-action-sheet - dm.tsx: optimistisches Reaction-Toggle + Delete-Confirm + Realtime-Refetch (Reaction-Changes + Partner-Soft-Delete) - useChatRealtime: DM-Hook lauscht zusätzlich auf reactions + message-UPDATE - PostCommentsSheet: optimistisches Herz + Realtime-Subscription + größeres Icon - i18n (de/en/fr/ar): chat.delete/message_deleted/delete_confirm_* + public_domain Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
589 lines
19 KiB
TypeScript
589 lines
19 KiB
TypeScript
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 { supabase } from '../lib/supabase';
|
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
|
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<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);
|
|
|
|
// 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<CommunityComment[]>({
|
|
queryKey: ['post-comments', postId],
|
|
queryFn: () => apiFetch(`/api/community/${postId}/comments`),
|
|
enabled: !!postId && visible,
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
// Realtime: revalidate comment list when any like on this post's comments changes.
|
|
// Requires rebreak.comment_likes to be in supabase_realtime publication — see
|
|
// backend migration TODO below.
|
|
useEffect(() => {
|
|
if (!postId || !visible) return;
|
|
let channel: RealtimeChannel | null = null;
|
|
let cancelled = false;
|
|
|
|
async function subscribe() {
|
|
const { data } = await supabase.auth.getSession();
|
|
if (!data.session?.access_token || cancelled) return;
|
|
|
|
channel = supabase
|
|
.channel(`comment-likes:${postId}:${Date.now()}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: '*', schema: 'rebreak', table: 'comment_likes' },
|
|
() => {
|
|
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
|
|
},
|
|
)
|
|
.subscribe();
|
|
}
|
|
|
|
subscribe();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (channel) supabase.removeChannel(channel);
|
|
};
|
|
}, [postId, visible, queryClient]);
|
|
|
|
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) => {
|
|
const queryKey = ['post-comments', postId];
|
|
const optimisticLiked = !comment.userLike;
|
|
const countDelta = optimisticLiked ? 1 : -1;
|
|
|
|
queryClient.setQueryData<CommunityComment[]>(queryKey, (prev) =>
|
|
prev?.map((c) =>
|
|
c.id === comment.id
|
|
? { ...c, userLike: optimisticLiked, likesCount: Math.max(0, c.likesCount + countDelta) }
|
|
: c,
|
|
),
|
|
);
|
|
|
|
try {
|
|
await apiFetch('/api/community/comment-like', {
|
|
method: 'POST',
|
|
body: { commentId: comment.id },
|
|
});
|
|
queryClient.invalidateQueries({ queryKey });
|
|
} catch {
|
|
queryClient.setQueryData<CommunityComment[]>(queryKey, (prev) =>
|
|
prev?.map((c) =>
|
|
c.id === comment.id
|
|
? { ...c, userLike: comment.userLike, likesCount: comment.likesCount }
|
|
: c,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
[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: keyboardHeight,
|
|
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]);
|
|
|
|
return (
|
|
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
|
|
<View style={{ marginTop: 2 }}>
|
|
<UserAvatar
|
|
userId={!isReply ? (comment.authorId ?? null) : null}
|
|
avatar={comment.authorAvatar ?? null}
|
|
nickname={comment.authorNickname ?? 'AN'}
|
|
size="sm"
|
|
showOnlineIndicator={!isReply}
|
|
/>
|
|
</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={20}
|
|
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
|
|
/>
|
|
</Animated.View>
|
|
{comment.likesCount > 0 && (
|
|
<Text style={{ fontSize: 9, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
|
|
{comment.likesCount}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|