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

695 lines
23 KiB
TypeScript

import { useRef, useState, useEffect, useMemo } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Dimensions,
} from 'react-native';
import { Image } from 'expo-image';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { useThemeStore } from '../../stores/theme';
import { UserAvatar } from '../UserAvatar';
import { MessageActionMenu, type AnchorRect } from './MessageActionMenu';
function fmtSec(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
const SCREEN_W = Dimensions.get('window').width;
function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: string; isOwn: boolean }) {
const colors = useColors();
const bubbleColors = useBubbleColors();
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [waveWidth, setWaveWidth] = useState(0);
const soundRef = useRef<Audio.Sound | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const totalSeconds = useMemo(() => {
const [m, s] = (duration ?? '0:00').split(':').map(Number);
return (m || 0) * 60 + (s || 0);
}, [duration]);
const barHeights = useMemo(() => {
const seed = url.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
// 80 bars, fixed 2dp width via space-between — screen-size-independent thinness
return Array.from({ length: 80 }, (_, i) => {
const a = Math.abs(Math.sin((seed * 0.019 + i) * 2.1));
const b = Math.abs(Math.sin((seed * 0.037 + i) * 3.7));
const c2 = Math.abs(Math.sin((seed * 0.073 + i) * 6.3));
const env = Math.pow(Math.abs(Math.sin((seed * 0.011 + i) * 0.95)), 0.5);
return Math.max(1.5, (a * 0.5 + b * 0.3 + c2 * 0.2) * env * 30);
});
}, [url]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
soundRef.current?.unloadAsync();
};
}, []);
async function togglePlay() {
if (isPlaying) {
await soundRef.current?.pauseAsync();
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setIsPlaying(false);
return;
}
try {
if (!soundRef.current) {
await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true });
const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true });
soundRef.current = sound;
sound.setOnPlaybackStatusUpdate((s) => {
if (s.isLoaded && s.didJustFinish) {
setIsPlaying(false);
setProgress(0);
setCurrentTime(0);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
}
});
} else {
await soundRef.current.playAsync();
}
setIsPlaying(true);
pollRef.current = setInterval(async () => {
const s = await soundRef.current?.getStatusAsync();
if (s?.isLoaded) {
setCurrentTime(Math.floor(s.positionMillis / 1000));
setProgress(s.durationMillis ? s.positionMillis / s.durationMillis : 0);
}
}, 100);
} catch (err) {
console.warn('[VoiceNote] play error:', err);
}
}
const playedCount = Math.floor(progress * barHeights.length);
const DOT_SIZE = 7;
const dotLeft = waveWidth > 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0;
const playBtnBg = isOwn ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.06)';
const playIconColor = isOwn ? bubbleColors.ownText : colors.text;
// WA-Stil: Bars immer dunkelgrau/schwarz — unabhängig von own/other
const playedBarColor = 'rgba(0,0,0,0.62)';
const unplayedBarColor = 'rgba(0,0,0,0.18)';
const dotColor = '#007AFF';
const durationColor = isOwn ? bubbleColors.ownText + '99' : colors.textMuted;
const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
const bubbleW = Math.floor(SCREEN_W * 0.60);
return (
<View style={{ width: bubbleW, paddingVertical: 2, paddingHorizontal: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<TouchableOpacity onPress={togglePlay} activeOpacity={0.7} hitSlop={8}>
<View style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: playBtnBg, alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name={isPlaying ? 'pause' : 'play'} size={13} color={playIconColor} style={{ marginLeft: isPlaying ? 0 : 1 }} />
</View>
</TouchableOpacity>
<View
style={{ flex: 1, height: 26, position: 'relative' }}
onLayout={(e) => setWaveWidth(e.nativeEvent.layout.width)}
>
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
{barHeights.map((h, i) => (
<View
key={i}
style={{ width: 2, height: h, borderRadius: 1, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
/>
))}
</View>
{waveWidth > 0 && (
<View style={{
position: 'absolute',
top: '50%',
marginTop: -(DOT_SIZE / 2),
left: dotLeft,
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: dotColor,
}} />
)}
</View>
</View>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: durationColor, marginTop: 2, marginLeft: 36, fontVariant: ['tabular-nums'] }}>
{displayDuration}
</Text>
</View>
);
}
export type MessageReaction = { emoji: string; count: number; mine: boolean };
export type ChatMsg = {
id: string;
userId: string;
nickname?: string | null;
avatar?: string | null;
content: string;
replyTo?: {
id: string;
userId: string;
nickname?: string | null;
content: string;
attachmentType?: string | null;
} | null;
attachmentUrl?: string | null;
attachmentType?: string | null;
attachmentName?: string | null;
likesCount: number;
likedByMe?: boolean;
createdAt: string;
isOwn: boolean;
readAt?: string | null;
/** Aggregierte Emoji-Reaktionen (DM). */
reactions?: MessageReaction[];
/** Soft-Delete-Tombstone. */
deleted?: boolean;
/** Optimistic-UI Status (pending = wird gesendet, failed = Fehler). */
status?: 'pending' | 'sent' | 'failed';
};
type Props = {
msg: ChatMsg;
showName?: boolean;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
hideReadStatus?: boolean;
/** Direct-Message-Mode: Likes als boolean-Herz (Insta-Style) statt Count, kein Avatar-Spalte-Whatever */
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;
};
function formatTime(ts: string) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function useBubbleColors() {
const colorScheme = useThemeStore((s) => s.colorScheme);
const isDark = colorScheme === 'dark';
return {
ownBg: isDark ? '#1e4d3a' : '#D1F4CC',
ownAudioBg: isDark ? '#1a4430' : '#C2EDBA',
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
otherBg: isDark ? '#2c2c2e' : '#ffffff',
otherAudioBg: isDark ? '#2c2c2e' : '#ffffff',
otherText: isDark ? '#ffffff' : '#0a0a0a',
replyBarColor: '#25D366',
readColor: '#34B7F1',
};
}
export function ChatBubble({
msg,
showName = false,
isFirstInGroup = true,
isLastInGroup = true,
hideReadStatus = false,
isDM = false,
onReply,
onLike,
onReact,
onDelete,
onOpenImage,
}: Props) {
const { t } = useTranslation();
const colors = useColors();
const styles = makeStyles(colors);
const bubbleColors = useBubbleColors();
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() {
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 =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
// isAudioMsg: hat Audio-Attachment (unabhängig von replyTo/content — für Styling)
const isAudioMsg = !!msg.attachmentUrl && msg.attachmentType === 'audio';
const isAudioOnly = isAudioMsg && !msg.content && !msg.replyTo;
const replyHasImage = msg.replyTo?.attachmentType === 'image';
const replyHasAudio = msg.replyTo?.attachmentType === 'audio';
const ownBubbleRadius = {
borderTopLeftRadius: 14,
borderTopRightRadius: isFirstInGroup ? 14 : 4,
borderBottomLeftRadius: 14,
borderBottomRightRadius: isLastInGroup ? 4 : 4,
};
const otherBubbleRadius = {
borderTopLeftRadius: isFirstInGroup ? 14 : 4,
borderTopRightRadius: 14,
borderBottomLeftRadius: isLastInGroup ? 4 : 4,
borderBottomRightRadius: 14,
};
const bubbleBg = msg.isOwn
? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg)
: (isAudioMsg ? bubbleColors.otherAudioBg : bubbleColors.otherBg);
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
function copyContent() {
if (msg.content) Clipboard.setStringAsync(msg.content);
}
// Scharfe Kopie der Bubble fürs Kontextmenü (bleibt über dem Blur sichtbar).
const previewNode = (
<View
style={[
styles.bubble,
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
{ backgroundColor: bubbleBg },
!msg.isOwn && styles.bubbleOtherBorder,
isImageOnly && { padding: 4 },
isAudioMsg && { paddingHorizontal: 6, paddingTop: 6, paddingBottom: 4 },
]}
>
{msg.attachmentUrl && msg.attachmentType === 'image' ? (
<Image source={{ uri: msg.attachmentUrl }} style={styles.image} contentFit="cover" cachePolicy="memory-disk" />
) : msg.attachmentUrl && msg.attachmentType === 'audio' ? (
<VoiceNoteBubble url={msg.attachmentUrl} duration={msg.attachmentName ?? '0:00'} isOwn={msg.isOwn} />
) : msg.content !== '' ? (
<Text style={[styles.content, { color: bubbleText }]}>{msg.content}</Text>
) : null}
</View>
);
return (
<>
<View
style={[
styles.row,
{ justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' },
{ marginTop: isFirstInGroup ? 8 : 2 },
]}
>
{!msg.isOwn && (
<View style={styles.avatarSlot}>
{isLastInGroup ? (
<UserAvatar
userId={msg.userId}
avatar={msg.avatar ?? null}
nickname={msg.nickname ?? '?'}
size="sm"
showOnlineIndicator={false}
/>
) : null}
</View>
)}
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }, isAudioMsg && styles.bubbleColAudio]}>
{showName && !msg.isOwn && isFirstInGroup && (
<Text style={styles.nickname} numberOfLines={1}>
{msg.nickname ?? '?'}
</Text>
)}
<TouchableOpacity
ref={bubbleRef}
delayLongPress={350}
onLongPress={openActions}
activeOpacity={1}
style={[
styles.bubble,
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
{ backgroundColor: bubbleBg },
!msg.isOwn && styles.bubbleOtherBorder,
isImageOnly && { padding: 4 },
isAudioMsg && { paddingHorizontal: 6, paddingTop: 6, paddingBottom: 4 },
msg.status === 'pending' && { opacity: 0.6 },
msg.status === 'failed' && { borderWidth: 1, borderColor: '#ef4444' },
]}
>
{msg.replyTo && (
<TouchableOpacity
activeOpacity={0.7}
style={[
styles.replyPreview,
{
backgroundColor: msg.isOwn
? 'rgba(0,0,0,0.08)'
: colors.surfaceElevated,
borderLeftColor: bubbleColors.replyBarColor,
},
]}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: bubbleColors.replyBarColor,
}}
numberOfLines={1}
>
{msg.replyTo.nickname ?? '?'}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 1,
}}
numberOfLines={1}
>
{replyHasImage && (
<Ionicons name="image" size={11} color={colors.textMuted} />
)}
{replyHasAudio && (
<Ionicons name="mic" size={11} color={colors.textMuted} />
)}{' '}
{msg.replyTo.content || (replyHasImage ? t('chat.image_attachment') : replyHasAudio ? `🎤 ${t('chat.voice_message')}` : '…')}
</Text>
</TouchableOpacity>
)}
{msg.attachmentUrl && msg.attachmentType === 'image' && (
<TouchableOpacity
onPress={() => onOpenImage(msg.attachmentUrl!)}
activeOpacity={0.7}
style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]}
>
<Image
source={{ uri: msg.attachmentUrl }}
style={styles.image}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
{isImageOnly && (
<View style={styles.imageTimeOverlay}>
{!isDM && msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" />
<Text style={{ fontSize: 10, color: '#fff', marginLeft: 2 }}>
{msg.likesCount}
</Text>
</View>
)}
<Text style={{ fontSize: 10, color: '#fff' }}>{formatTime(msg.createdAt)}</Text>
</View>
)}
</TouchableOpacity>
)}
{msg.attachmentUrl && msg.attachmentType === 'audio' && (
<VoiceNoteBubble
url={msg.attachmentUrl}
duration={msg.attachmentName ?? '0:00'}
isOwn={msg.isOwn}
/>
)}
{msg.attachmentUrl && msg.attachmentType !== 'image' && msg.attachmentType !== 'audio' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 10,
marginBottom: 4,
backgroundColor: 'rgba(0,0,0,0.06)',
}}
>
<Ionicons name="document-attach" size={18} color={colors.textMuted} />
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
marginLeft: 8,
color: bubbleText,
flex: 1,
}}
numberOfLines={1}
>
{msg.attachmentName ?? t('chat.file_attachment')}
</Text>
</View>
)}
{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}>
{!isDM && msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" />
<Text
style={{
fontSize: 10,
marginLeft: 2,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
>
{msg.likesCount}
</Text>
</View>
)}
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(0,0,0,0.45)' : colors.textMuted,
}}
>
{formatTime(msg.createdAt)}
</Text>
{isDM && msg.isOwn && msg.status !== 'pending' && msg.status !== 'failed' && (
<Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={12}
color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
style={{ marginLeft: 2 }}
/>
)}
{msg.status === 'pending' && (
<Ionicons
name="time-outline"
size={11}
color="rgba(0,0,0,0.35)"
style={{ marginLeft: 2 }}
/>
)}
{msg.status === 'failed' && (
<Ionicons
name="alert-circle"
size={11}
color="#ef4444"
style={{ marginLeft: 2 }}
/>
)}
</View>
)}
</TouchableOpacity>
{/* Insta-Style: kleines Herz-Badge hängt unter der Bubble (nur DM, nur wenn liked) */}
{isDM && msg.likedByMe && (
<TouchableOpacity
onPress={() => onLike(msg)}
activeOpacity={0.7}
hitSlop={8}
style={[
styles.dmHeartBadge,
{
alignSelf: msg.isOwn ? 'flex-end' : 'flex-start',
marginRight: msg.isOwn ? 8 : 0,
marginLeft: msg.isOwn ? 0 : 8,
},
]}
>
<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.6}
style={styles.reactionPill}
>
<Text style={{ fontSize: 16 }}>{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}
preview={previewNode}
onClose={() => setMenuVisible(false)}
onReact={(emoji) => onReact?.(msg, emoji)}
onReply={() => onReply(msg)}
onCopy={copyContent}
onDelete={() => onDelete?.(msg)}
/>
</>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
return StyleSheet.create({
row: {
flexDirection: 'row',
paddingHorizontal: 10,
},
avatarSlot: {
width: 32,
marginRight: 6,
justifyContent: 'flex-end',
},
reactionPills: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: -6,
marginBottom: 2,
},
reactionPill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 2,
},
reactionPillCount: {
fontSize: 11,
marginLeft: 3,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
},
bubbleCol: {
maxWidth: '76%',
},
bubbleColAudio: {
maxWidth: '84%',
},
nickname: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#25D366',
marginBottom: 3,
marginLeft: 12,
},
bubble: {
paddingHorizontal: 12,
paddingTop: 8,
paddingBottom: 6,
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
bubbleOtherBorder: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0,0,0,0.06)',
},
replyPreview: {
borderLeftWidth: 4,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 4,
marginBottom: 6,
},
imageWrap: {
borderRadius: 10,
overflow: 'hidden',
position: 'relative',
},
image: {
width: 220,
height: 220,
backgroundColor: colors.surfaceElevated,
},
imageTimeOverlay: {
position: 'absolute',
bottom: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
flexDirection: 'row',
alignItems: 'center',
},
content: {
fontSize: 14,
lineHeight: 21,
fontFamily: 'Nunito_400Regular',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 3,
marginTop: 4,
alignSelf: 'flex-end',
},
dmHeartBadge: {
marginTop: -6,
backgroundColor: colors.bg,
borderRadius: 999,
paddingHorizontal: 4,
paddingVertical: 3,
shadowColor: '#000',
shadowOpacity: 0.12,
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
elevation: 2,
},
});
}