chahinebrini 1dc4e4f9cd fix(chat): voice bubble preview in action menu + popup positioning
- previewNode: add audio case → VoiceNoteBubble renders correctly in blur
  overlay when long-pressing a voice message (was rendering empty/null)
- MessageActionMenu: account for input bar (bottomInset=90) in positioning —
  menu no longer slides behind input bar on bottom messages; barTop clamped
  to never overlap the menu; both above/below paths respect usableH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 08:44:53 +02:00

246 lines
7.4 KiB
TypeScript

/**
* 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, type ReactNode } 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;
/** Scharfe Kopie der gedrückten Bubble — bleibt über dem Blur sichtbar (WA-Stil). */
preview?: ReactNode;
onClose: () => void;
onReact: (emoji: string) => void;
onReply: () => void;
onCopy: () => void;
onDelete: () => void;
};
export function MessageActionMenu({
visible,
anchor,
isOwn,
hasContent,
myReaction,
preview,
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;
// Input-Bar + Safe-Area am unteren Bildschirmrand (Popup darf nie darunter)
const bottomInset = 90;
const usableH = screenH - bottomInset;
// Menü unter der Bubble wenn genug Platz, sonst darüber — Input-Bar berücksichtigt
const belowSpace = usableH - (anchor.y + anchor.height);
const placeBelow = belowSpace >= estMenuH + 8;
const menuTopIdeal = placeBelow
? anchor.y + anchor.height + 8
: anchor.y - estMenuH - 8;
// Clampen: nie über Safe-Area oben, nie unter Input-Bar unten
const menuTop = Math.min(
Math.max(topSafe + barH + 8, menuTopIdeal),
usableH - estMenuH,
);
// Reaktions-Leiste über der Bubble — nie über topSafe, nie das Menü überlappen
const barTopIdeal = Math.max(topSafe, anchor.y - barH - 4);
const barTop = Math.min(barTopIdeal, menuTop - barH - 8);
// 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)' }]} />
)}
{/* Scharfe Kopie der gedrückten Bubble — bleibt über dem Blur sichtbar (WA-Stil) */}
{preview && (
<View
pointerEvents="none"
style={[
{ position: 'absolute', top: anchor.y, width: anchor.width },
isOwn
? { right: Math.max(12, screenW - (anchor.x + anchor.width)) }
: { left: Math.max(12, anchor.x) },
]}
>
{preview}
</View>
)}
{/* Emoji-Reaktions-Leiste (nur fremde Nachrichten) */}
{showReactions && (
<View style={[styles.reactionBar, sideStyle, { top: barTop, backgroundColor: colors.surface }]}>
{REACTION_EMOJIS.map((emoji) => (
<TouchableOpacity
key={emoji}
activeOpacity={0.5}
onPress={() => {
onReact(emoji);
onClose();
}}
style={styles.reactionBtn}
>
<Text style={[styles.reactionEmoji, myReaction === emoji && styles.reactionEmojiActive]}>
{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 },
reactionEmojiActive: { fontSize: 30 },
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' },
});