feat(chat): WhatsApp-Style Reaktions-/Aktions-Popup (DM) + Reaction-Pills

- 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>
This commit is contained in:
chahinebrini 2026-05-30 11:18:51 +02:00
parent 69f01c5a0c
commit 2591b2a89c
9 changed files with 494 additions and 33 deletions

View File

@ -23,7 +23,7 @@ import * as ImagePicker from 'expo-image-picker';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { useColors } from '../lib/theme';
@ -158,6 +158,8 @@ export default function DmScreen() {
createdAt: m.createdAt,
isOwn: m.isOwn,
readAt: m.readAt,
reactions: m.reactions ?? [],
deleted: m.deleted ?? false,
}));
setMessages(msgs);
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
@ -210,7 +212,11 @@ export default function DmScreen() {
},
[myUserId],
);
useDmRealtime(userId, onDmInsert, !!myUserId);
// Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
const refetchHistory = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
}, [queryClient, userId]);
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
async function pickImage() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
@ -333,6 +339,80 @@ export default function DmScreen() {
} catch {}
}
// Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste
// (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion).
function applyReactionOptimistic(
reactions: MessageReaction[],
emoji: string,
): MessageReaction[] {
const list = reactions.map((r) => ({ ...r }));
const mine = list.find((r) => r.mine);
const toggledOff = mine?.emoji === emoji;
if (mine) {
mine.count -= 1;
mine.mine = false;
}
const cleaned = list.filter((r) => r.count > 0);
if (toggledOff) return cleaned;
const existing = cleaned.find((r) => r.emoji === emoji);
if (existing) {
existing.count += 1;
existing.mine = true;
} else {
cleaned.push({ emoji, count: 1, mine: true });
}
return cleaned;
}
async function toggleReaction(msg: ChatMsg, emoji: string) {
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) }
: m,
),
);
try {
await apiFetch('/api/chat/reaction', {
method: 'POST',
body: { messageId: msg.id, emoji },
});
} catch {
refetchHistory(); // Rollback via Server-State
}
}
function deleteMessage(msg: ChatMsg) {
Alert.alert(
t('chat.delete_confirm_title'),
t('chat.delete_confirm_msg'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('chat.delete'),
style: 'destructive',
onPress: async () => {
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] }
: m,
),
);
try {
await apiFetch('/api/chat/delete-message', {
method: 'POST',
body: { messageId: msg.id },
});
} catch {
refetchHistory();
}
},
},
],
);
}
function startReply(msg: ChatMsg) {
setReplyTo({
id: msg.id,
@ -397,6 +477,8 @@ export default function DmScreen() {
isLastInGroup={!sameAuthor(item, messages[index + 1])}
onReply={startReply}
onLike={toggleLike}
onReact={toggleReaction}
onDelete={deleteMessage}
onOpenImage={() => {}}
/>
)}

View File

@ -21,6 +21,8 @@ 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';
@ -175,6 +177,38 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
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);
@ -205,14 +239,32 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
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: ['post-comments', postId] });
queryClient.invalidateQueries({ queryKey });
} catch {
// ignore
queryClient.setQueryData<CommunityComment[]>(queryKey, (prev) =>
prev?.map((c) =>
c.id === comment.id
? { ...c, userLike: comment.userLike, likesCount: comment.likesCount }
: c,
),
);
}
},
[postId, queryClient],
@ -521,7 +573,7 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={comment.userLike ? 'heart' : 'heart-outline'}
size={16}
size={20}
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
/>
</Animated.View>

View File

@ -1,3 +1,4 @@
import { useRef, useState } from 'react';
import {
View,
Text,
@ -8,10 +9,12 @@ import { Image } from 'expo-image';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
import { UserAvatar } from '../UserAvatar';
import { MessageActionMenu, type AnchorRect } from './MessageActionMenu';
export type MessageReaction = { emoji: string; count: number; mine: boolean };
export type ChatMsg = {
id: string;
@ -34,6 +37,10 @@ export type ChatMsg = {
createdAt: string;
isOwn: boolean;
readAt?: string | null;
/** Aggregierte Emoji-Reaktionen (DM). */
reactions?: MessageReaction[];
/** Soft-Delete-Tombstone. */
deleted?: boolean;
};
type Props = {
@ -46,6 +53,10 @@ type Props = {
isDM?: boolean;
onReply: (msg: ChatMsg) => void;
onLike: (msg: ChatMsg) => void;
/** DM-only: Emoji-Reaktion togglen. Group-Chat (deaktiviert) übergibt nichts. */
onReact?: (msg: ChatMsg, emoji: string) => void;
/** DM-only: eigene Nachricht löschen (Soft-Delete). */
onDelete?: (msg: ChatMsg) => void;
onOpenImage: (url: string) => void;
};
@ -75,38 +86,26 @@ export function ChatBubble({
isDM = false,
onReply,
onLike,
onReact,
onDelete,
onOpenImage,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const bubbleColors = useBubbleColors();
const { showActionSheetWithOptions } = useActionSheet();
const bubbleRef = useRef<View>(null);
const [menuVisible, setMenuVisible] = useState(false);
const [anchor, setAnchor] = useState<AnchorRect | null>(null);
const hasContent = msg.content !== '';
const myReaction = msg.reactions?.find((r) => r.mine)?.emoji ?? null;
function openActions() {
const hasContent = msg.content !== '';
const likeLabel = msg.likedByMe ? t('chat.unlike') : t('chat.like');
const options: string[] = [t('chat.reply'), likeLabel];
if (hasContent) options.push(t('chat.copy'));
options.push(t('common.cancel'));
const cancelButtonIndex = options.length - 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
title: hasContent
? msg.content.length > 60
? msg.content.slice(0, 60) + '…'
: msg.content
: undefined,
},
(selected?: number) => {
if (selected === undefined || selected === cancelButtonIndex) return;
if (selected === 0) onReply(msg);
else if (selected === 1) onLike(msg);
else if (selected === 2 && hasContent) copyContent();
},
);
if (msg.deleted) return; // gelöschte Nachrichten: kein Kontextmenü
bubbleRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({ x, y, width, height });
setMenuVisible(true);
});
}
const isImageOnly =
@ -163,6 +162,7 @@ export function ChatBubble({
)}
<TouchableOpacity
ref={bubbleRef}
delayLongPress={350}
onLongPress={openActions}
activeOpacity={1}
@ -271,11 +271,15 @@ export function ChatBubble({
</View>
)}
{msg.content !== '' && (
{msg.deleted ? (
<Text style={[styles.content, { color: bubbleText, fontStyle: 'italic', opacity: 0.6 }]}>
{t('chat.message_deleted')}
</Text>
) : msg.content !== '' ? (
<Text style={[styles.content, { color: bubbleText }]}>
{msg.content}
</Text>
)}
) : null}
{!isImageOnly && (
<View style={styles.footer}>
@ -333,8 +337,49 @@ export function ChatBubble({
<Ionicons name="heart" size={12} color="#f87171" />
</TouchableOpacity>
)}
{/* Emoji-Reaktions-Pills unter der Bubble (DM) */}
{msg.reactions && msg.reactions.length > 0 && (
<View
style={[
styles.reactionPills,
{ alignSelf: msg.isOwn ? 'flex-end' : 'flex-start' },
]}
>
{msg.reactions.map((r) => (
<TouchableOpacity
key={r.emoji}
onPress={() => onReact?.(msg, r.emoji)}
activeOpacity={0.7}
style={[
styles.reactionPill,
{
backgroundColor: colors.surfaceElevated,
borderColor: r.mine ? colors.brandOrange : colors.border,
},
]}
>
<Text style={{ fontSize: 13 }}>{r.emoji}</Text>
{r.count > 1 && <Text style={styles.reactionPillCount}>{r.count}</Text>}
</TouchableOpacity>
))}
</View>
)}
</View>
</View>
<MessageActionMenu
visible={menuVisible}
anchor={anchor}
isOwn={msg.isOwn}
hasContent={hasContent}
myReaction={myReaction}
onClose={() => setMenuVisible(false)}
onReact={(emoji) => onReact?.(msg, emoji)}
onReply={() => onReply(msg)}
onCopy={copyContent}
onDelete={() => onDelete?.(msg)}
/>
</>
);
}
@ -350,6 +395,27 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
marginRight: 6,
justifyContent: 'flex-end',
},
reactionPills: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: -6,
marginBottom: 2,
},
reactionPill: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
borderWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 7,
paddingVertical: 2,
},
reactionPillCount: {
fontSize: 11,
marginLeft: 3,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
},
bubbleCol: {
maxWidth: '76%',
},

View File

@ -0,0 +1,216 @@
/**
* MessageActionMenu WhatsApp-Style Long-Press-Kontextmenü für Chat-Bubbles.
*
* Ersetzt den zentriert gerenderten @expo/react-native-action-sheet. Wird an
* der gedrückten Bubble verankert (per measureInWindow gelieferte `anchor`-Rect):
* - Emoji-Reaktions-Leiste OBEN (nur bei fremden Nachrichten)
* - Aktions-Liste UNTEN (fremd: Antworten + Kopieren / eigen: Kopieren + Löschen)
* Blur-Backdrop (iOS) / semi-transparent (Android). Smart-Position: Menü unter
* der Bubble, oder darüber wenn unten kein Platz ist.
*/
import { useMemo } from 'react';
import {
Dimensions,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
useColorScheme,
} from 'react-native';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
const IS_IOS = Platform.OS === 'ios';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🙏', '👏'];
export type AnchorRect = { x: number; y: number; width: number; height: number };
type Props = {
visible: boolean;
anchor: AnchorRect | null;
isOwn: boolean;
hasContent: boolean;
/** Aktuelles eigenes Reaktions-Emoji auf dieser Message (für Highlight). */
myReaction?: string | null;
onClose: () => void;
onReact: (emoji: string) => void;
onReply: () => void;
onCopy: () => void;
onDelete: () => void;
};
export function MessageActionMenu({
visible,
anchor,
isOwn,
hasContent,
myReaction,
onClose,
onReact,
onReply,
onCopy,
onDelete,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const scheme = useColorScheme();
const actions = useMemo(() => {
const list: {
key: string;
label: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
danger?: boolean;
onPress: () => void;
}[] = [];
if (!isOwn) {
list.push({ key: 'reply', label: t('chat.reply'), icon: 'arrow-undo-outline', onPress: onReply });
}
if (hasContent) {
list.push({ key: 'copy', label: t('chat.copy'), icon: 'copy-outline', onPress: onCopy });
}
if (isOwn) {
list.push({ key: 'delete', label: t('chat.delete'), icon: 'trash-outline', danger: true, onPress: onDelete });
}
return list;
}, [isOwn, hasContent, t, onReply, onCopy, onDelete]);
if (!anchor) return null;
const screenW = Dimensions.get('window').width;
const screenH = Dimensions.get('window').height;
const topSafe = 60;
const showReactions = !isOwn;
const barH = showReactions ? 60 : 0;
const estMenuH = actions.length * 52 + 12;
// Menü unter der Bubble, sonst darüber.
const belowSpace = screenH - (anchor.y + anchor.height);
const placeBelow = belowSpace > estMenuH + 40;
const menuTop = placeBelow
? anchor.y + anchor.height + 10
: Math.max(topSafe + barH + 10, anchor.y - estMenuH - 10);
// Reaktions-Leiste über der Bubble (geclampt unter die Safe-Area).
const barTop = Math.max(topSafe, anchor.y - barH - 4);
// Horizontale Ausrichtung an der Bubble-Seite.
const sideStyle = isOwn
? { right: Math.max(12, screenW - (anchor.x + anchor.width)) }
: { left: Math.max(12, anchor.x) };
return (
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onClose}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose}>
{IS_IOS ? (
<BlurView
intensity={24}
tint={scheme === 'dark' ? 'dark' : 'light'}
style={StyleSheet.absoluteFill}
/>
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.28)' }]} />
)}
{/* Emoji-Reaktions-Leiste (nur fremde Nachrichten) */}
{showReactions && (
<View style={[styles.reactionBar, sideStyle, { top: barTop, backgroundColor: colors.surface }]}>
{REACTION_EMOJIS.map((emoji) => {
const active = myReaction === emoji;
return (
<TouchableOpacity
key={emoji}
activeOpacity={0.6}
onPress={() => {
onReact(emoji);
onClose();
}}
style={[styles.reactionBtn, active && { backgroundColor: colors.surfaceElevated }]}
>
<Text style={styles.reactionEmoji}>{emoji}</Text>
</TouchableOpacity>
);
})}
</View>
)}
{/* Aktions-Liste */}
<View style={[styles.menu, sideStyle, { top: menuTop, backgroundColor: colors.surface }]}>
{actions.map((a, i) => (
<TouchableOpacity
key={a.key}
activeOpacity={0.6}
onPress={() => {
a.onPress();
onClose();
}}
style={[
styles.menuItem,
i < actions.length - 1 && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border },
]}
>
<Text
style={[
styles.menuLabel,
{ color: a.danger ? '#dc2626' : colors.text },
]}
>
{a.label}
</Text>
<Ionicons name={a.icon} size={19} color={a.danger ? '#dc2626' : colors.textMuted} />
</TouchableOpacity>
))}
</View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
reactionBar: {
position: 'absolute',
flexDirection: 'row',
alignItems: 'center',
borderRadius: 26,
paddingHorizontal: 6,
paddingVertical: 5,
gap: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
},
reactionBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
reactionEmoji: { fontSize: 26 },
menu: {
position: 'absolute',
minWidth: 200,
borderRadius: 14,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.16,
shadowRadius: 16,
elevation: 10,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 15,
},
menuLabel: { fontSize: 15, fontFamily: 'Nunito_600SemiBold' },
});

View File

@ -12,6 +12,12 @@ export function useDmRealtime(
partnerId: string | undefined,
onInsert: (row: any) => void,
enabled: boolean = true,
// Partner UPDATEt eine eigene Message (z.B. Soft-Delete deleted_at, read_at).
onUpdate?: (row: any) => void,
// Irgendeine DM-Reaktion hat sich geändert → Caller refetcht die Konversation.
// (Die direct_message_reactions-Tabelle hat keine Partner-Spalte zum Filtern;
// für DM-Volumen ist ein Refetch-on-any akzeptabel.)
onReactionChange?: () => void,
) {
useEffect(() => {
if (!enabled || !partnerId) return;
@ -37,6 +43,29 @@ export function useDmRealtime(
onInsert(payload.new);
},
)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "rebreak",
table: "direct_messages",
filter: `sender_id=eq.${partnerId}`,
},
(payload: any) => {
onUpdate?.(payload.new);
},
)
.on(
"postgres_changes",
{
event: "*",
schema: "rebreak",
table: "direct_message_reactions",
},
() => {
onReactionChange?.();
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
@ -61,7 +90,7 @@ export function useDmRealtime(
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [partnerId, enabled, onInsert]);
}, [partnerId, enabled, onInsert, onUpdate, onReactionChange]);
}
/**

View File

@ -956,6 +956,10 @@
"like": "إعجاب",
"unlike": "إزالة الإعجاب",
"copy": "نسخ",
"delete": "حذف",
"message_deleted": "تم حذف الرسالة",
"delete_confirm_title": "حذف الرسالة؟",
"delete_confirm_msg": "سيتم حذف هذه الرسالة للجميع — لا يمكن التراجع عن ذلك.",
"image_attachment": "صورة",
"file_attachment": "ملف",
"upload_failed": "فشل الرفع",

View File

@ -1021,6 +1021,10 @@
"like": "Liken",
"unlike": "Like entfernen",
"copy": "Kopieren",
"delete": "Löschen",
"message_deleted": "Nachricht gelöscht",
"delete_confirm_title": "Nachricht löschen?",
"delete_confirm_msg": "Diese Nachricht wird für alle gelöscht — das lässt sich nicht rückgängig machen.",
"image_attachment": "Bild",
"file_attachment": "Datei",
"upload_failed": "Upload fehlgeschlagen",

View File

@ -1021,6 +1021,10 @@
"like": "Like",
"unlike": "Unlike",
"copy": "Copy",
"delete": "Delete",
"message_deleted": "Message deleted",
"delete_confirm_title": "Delete message?",
"delete_confirm_msg": "This message will be deleted for everyone — this can't be undone.",
"image_attachment": "Image",
"file_attachment": "File",
"upload_failed": "Upload failed",

View File

@ -943,6 +943,10 @@
"like": "J'aime",
"unlike": "Retirer le j'aime",
"copy": "Copier",
"delete": "Supprimer",
"message_deleted": "Message supprimé",
"delete_confirm_title": "Supprimer le message ?",
"delete_confirm_msg": "Ce message sera supprimé pour tout le monde — c'est irréversible.",
"image_attachment": "Image",
"file_attachment": "Fichier",
"upload_failed": "Échec du téléversement",