- 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>
246 lines
7.4 KiB
TypeScript
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' },
|
|
});
|