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:
parent
69f01c5a0c
commit
2591b2a89c
@ -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
|
// 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 * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { apiFetch } from '../lib/api';
|
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 { DmChatBackground } from '../components/chat/DmChatBackground';
|
||||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
@ -158,6 +158,8 @@ export default function DmScreen() {
|
|||||||
createdAt: m.createdAt,
|
createdAt: m.createdAt,
|
||||||
isOwn: m.isOwn,
|
isOwn: m.isOwn,
|
||||||
readAt: m.readAt,
|
readAt: m.readAt,
|
||||||
|
reactions: m.reactions ?? [],
|
||||||
|
deleted: m.deleted ?? false,
|
||||||
}));
|
}));
|
||||||
setMessages(msgs);
|
setMessages(msgs);
|
||||||
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
|
requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false }));
|
||||||
@ -210,7 +212,11 @@ export default function DmScreen() {
|
|||||||
},
|
},
|
||||||
[myUserId],
|
[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() {
|
async function pickImage() {
|
||||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
@ -333,6 +339,80 @@ export default function DmScreen() {
|
|||||||
} catch {}
|
} 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) {
|
function startReply(msg: ChatMsg) {
|
||||||
setReplyTo({
|
setReplyTo({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
@ -397,6 +477,8 @@ export default function DmScreen() {
|
|||||||
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
||||||
onReply={startReply}
|
onReply={startReply}
|
||||||
onLike={toggleLike}
|
onLike={toggleLike}
|
||||||
|
onReact={toggleReaction}
|
||||||
|
onDelete={deleteMessage}
|
||||||
onOpenImage={() => {}}
|
onOpenImage={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
import { formatRelativeTime } from '../lib/formatTime';
|
import { formatRelativeTime } from '../lib/formatTime';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
import type { CommunityComment } from '../stores/community';
|
import type { CommunityComment } from '../stores/community';
|
||||||
import { UserAvatar } from './UserAvatar';
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
@ -175,6 +177,38 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
staleTime: 30_000,
|
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 topLevel = comments.filter((c) => !c.parentCommentId);
|
||||||
const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id);
|
const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id);
|
||||||
|
|
||||||
@ -205,14 +239,32 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
|||||||
|
|
||||||
const likeComment = useCallback(
|
const likeComment = useCallback(
|
||||||
async (comment: CommunityComment) => {
|
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 {
|
try {
|
||||||
await apiFetch('/api/community/comment-like', {
|
await apiFetch('/api/community/comment-like', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { commentId: comment.id },
|
body: { commentId: comment.id },
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
queryClient.setQueryData<CommunityComment[]>(queryKey, (prev) =>
|
||||||
|
prev?.map((c) =>
|
||||||
|
c.id === comment.id
|
||||||
|
? { ...c, userLike: comment.userLike, likesCount: comment.likesCount }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[postId, queryClient],
|
[postId, queryClient],
|
||||||
@ -521,7 +573,7 @@ function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowPro
|
|||||||
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={comment.userLike ? 'heart' : 'heart-outline'}
|
name={comment.userLike ? 'heart' : 'heart-outline'}
|
||||||
size={16}
|
size={20}
|
||||||
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
|
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -8,10 +9,12 @@ import { Image } from 'expo-image';
|
|||||||
import * as Clipboard from 'expo-clipboard';
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useActionSheet } from '@expo/react-native-action-sheet';
|
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
import { useThemeStore } from '../../stores/theme';
|
import { useThemeStore } from '../../stores/theme';
|
||||||
import { UserAvatar } from '../UserAvatar';
|
import { UserAvatar } from '../UserAvatar';
|
||||||
|
import { MessageActionMenu, type AnchorRect } from './MessageActionMenu';
|
||||||
|
|
||||||
|
export type MessageReaction = { emoji: string; count: number; mine: boolean };
|
||||||
|
|
||||||
export type ChatMsg = {
|
export type ChatMsg = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -34,6 +37,10 @@ export type ChatMsg = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
isOwn: boolean;
|
isOwn: boolean;
|
||||||
readAt?: string | null;
|
readAt?: string | null;
|
||||||
|
/** Aggregierte Emoji-Reaktionen (DM). */
|
||||||
|
reactions?: MessageReaction[];
|
||||||
|
/** Soft-Delete-Tombstone. */
|
||||||
|
deleted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -46,6 +53,10 @@ type Props = {
|
|||||||
isDM?: boolean;
|
isDM?: boolean;
|
||||||
onReply: (msg: ChatMsg) => void;
|
onReply: (msg: ChatMsg) => void;
|
||||||
onLike: (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;
|
onOpenImage: (url: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,38 +86,26 @@ export function ChatBubble({
|
|||||||
isDM = false,
|
isDM = false,
|
||||||
onReply,
|
onReply,
|
||||||
onLike,
|
onLike,
|
||||||
|
onReact,
|
||||||
|
onDelete,
|
||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
const bubbleColors = useBubbleColors();
|
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() {
|
function openActions() {
|
||||||
const hasContent = msg.content !== '';
|
if (msg.deleted) return; // gelöschte Nachrichten: kein Kontextmenü
|
||||||
const likeLabel = msg.likedByMe ? t('chat.unlike') : t('chat.like');
|
bubbleRef.current?.measureInWindow((x, y, width, height) => {
|
||||||
const options: string[] = [t('chat.reply'), likeLabel];
|
setAnchor({ x, y, width, height });
|
||||||
if (hasContent) options.push(t('chat.copy'));
|
setMenuVisible(true);
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImageOnly =
|
const isImageOnly =
|
||||||
@ -163,6 +162,7 @@ export function ChatBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
ref={bubbleRef}
|
||||||
delayLongPress={350}
|
delayLongPress={350}
|
||||||
onLongPress={openActions}
|
onLongPress={openActions}
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
@ -271,11 +271,15 @@ export function ChatBubble({
|
|||||||
</View>
|
</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 }]}>
|
<Text style={[styles.content, { color: bubbleText }]}>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{!isImageOnly && (
|
{!isImageOnly && (
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
@ -333,8 +337,49 @@ export function ChatBubble({
|
|||||||
<Ionicons name="heart" size={12} color="#f87171" />
|
<Ionicons name="heart" size={12} color="#f87171" />
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
</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,
|
marginRight: 6,
|
||||||
justifyContent: 'flex-end',
|
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: {
|
bubbleCol: {
|
||||||
maxWidth: '76%',
|
maxWidth: '76%',
|
||||||
},
|
},
|
||||||
|
|||||||
216
apps/rebreak-native/components/chat/MessageActionMenu.tsx
Normal file
216
apps/rebreak-native/components/chat/MessageActionMenu.tsx
Normal 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' },
|
||||||
|
});
|
||||||
@ -12,6 +12,12 @@ export function useDmRealtime(
|
|||||||
partnerId: string | undefined,
|
partnerId: string | undefined,
|
||||||
onInsert: (row: any) => void,
|
onInsert: (row: any) => void,
|
||||||
enabled: boolean = true,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !partnerId) return;
|
if (!enabled || !partnerId) return;
|
||||||
@ -37,6 +43,29 @@ export function useDmRealtime(
|
|||||||
onInsert(payload.new);
|
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) => {
|
.subscribe((status) => {
|
||||||
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
|
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
|
||||||
cleanup();
|
cleanup();
|
||||||
@ -61,7 +90,7 @@ export function useDmRealtime(
|
|||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, [partnerId, enabled, onInsert]);
|
}, [partnerId, enabled, onInsert, onUpdate, onReactionChange]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -956,6 +956,10 @@
|
|||||||
"like": "إعجاب",
|
"like": "إعجاب",
|
||||||
"unlike": "إزالة الإعجاب",
|
"unlike": "إزالة الإعجاب",
|
||||||
"copy": "نسخ",
|
"copy": "نسخ",
|
||||||
|
"delete": "حذف",
|
||||||
|
"message_deleted": "تم حذف الرسالة",
|
||||||
|
"delete_confirm_title": "حذف الرسالة؟",
|
||||||
|
"delete_confirm_msg": "سيتم حذف هذه الرسالة للجميع — لا يمكن التراجع عن ذلك.",
|
||||||
"image_attachment": "صورة",
|
"image_attachment": "صورة",
|
||||||
"file_attachment": "ملف",
|
"file_attachment": "ملف",
|
||||||
"upload_failed": "فشل الرفع",
|
"upload_failed": "فشل الرفع",
|
||||||
|
|||||||
@ -1021,6 +1021,10 @@
|
|||||||
"like": "Liken",
|
"like": "Liken",
|
||||||
"unlike": "Like entfernen",
|
"unlike": "Like entfernen",
|
||||||
"copy": "Kopieren",
|
"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",
|
"image_attachment": "Bild",
|
||||||
"file_attachment": "Datei",
|
"file_attachment": "Datei",
|
||||||
"upload_failed": "Upload fehlgeschlagen",
|
"upload_failed": "Upload fehlgeschlagen",
|
||||||
|
|||||||
@ -1021,6 +1021,10 @@
|
|||||||
"like": "Like",
|
"like": "Like",
|
||||||
"unlike": "Unlike",
|
"unlike": "Unlike",
|
||||||
"copy": "Copy",
|
"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",
|
"image_attachment": "Image",
|
||||||
"file_attachment": "File",
|
"file_attachment": "File",
|
||||||
"upload_failed": "Upload failed",
|
"upload_failed": "Upload failed",
|
||||||
|
|||||||
@ -943,6 +943,10 @@
|
|||||||
"like": "J'aime",
|
"like": "J'aime",
|
||||||
"unlike": "Retirer le j'aime",
|
"unlike": "Retirer le j'aime",
|
||||||
"copy": "Copier",
|
"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",
|
"image_attachment": "Image",
|
||||||
"file_attachment": "Fichier",
|
"file_attachment": "Fichier",
|
||||||
"upload_failed": "Échec du téléversement",
|
"upload_failed": "Échec du téléversement",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user