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 soundRef = useRef(null); const pollRef = useRef | null>(null); // Merkt sich ob die Wiedergabe komplett durchlief — dann muss der nächste // Play von vorne (replayAsync) statt am Ende-stehengebliebenen playAsync. const finishedRef = useRef(false); const totalSeconds = useMemo(() => { const [m, s] = (duration ?? '0:00').split(':').map(Number); return (m || 0) * 60 + (s || 0); }, [duration]); // WhatsApp-Look: ~34 dickere Balken mit deutlich variierender Höhe statt // 80 gleichförmiger dünner Striche (sah „hardcodiert" aus). Deterministischer // LCG-PRNG (aus URL geseedet) → pro Sprachnachricht stabil, aber natürliche // Amplituden-Streuung wie eine echte Sprach-Wellenform. const barHeights = useMemo(() => { let s = url.split('').reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) >>> 0, 7) || 1; const rand = () => { s = (s * 1103515245 + 12345) >>> 0; return s / 0xffffffff; }; const MAX_H = 24; return Array.from({ length: 34 }, (_, i) => { // Sanfte Sprech-Hüllkurve (steigt/fällt) × Zufalls-Spitzen const env = 0.45 + 0.55 * Math.abs(Math.sin((i / 34) * Math.PI * 2.3 + (s % 5))); const peak = 0.2 + 0.8 * rand(); return Math.max(3, peak * env * MAX_H); }); }, [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; finishedRef.current = false; sound.setOnPlaybackStatusUpdate((s) => { if (s.isLoaded && s.didJustFinish) { finishedRef.current = true; setIsPlaying(false); setProgress(0); setCurrentTime(0); if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } }); } else if (finishedRef.current) { // Nach komplettem Durchlauf: von vorne abspielen (Position steht am Ende) finishedRef.current = false; setProgress(0); setCurrentTime(0); await soundRef.current.replayAsync(); } else { // Resume nach Pause: Position beibehalten 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); // Insta-Style Wellenform-Farben: // - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte // halbtransparent-weiß (grau wirkend). // - fremde Bubble (graue Clean-Bubble): Inhalt schwarz, gespielte Bars // schwarz, ungespielte grau. // Im Ruhezustand (noch nicht gestartet ODER nach komplettem Durchlauf) sind // ALLE Bars in der Vollfarbe (schwarz / weiß) — die Zwei-Ton-Progress-Ansicht // erscheint nur während/nach dem Abspielen. const fullBarColor = isOwn ? '#ffffff' : '#0a0a0a'; const dimBarColor = isOwn ? 'rgba(255,255,255,0.42)' : 'rgba(0,0,0,0.26)'; const showFullBars = !isPlaying && progress === 0; const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)'; const playIconColor = isOwn ? '#ffffff' : colors.text; const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : colors.textMuted; const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds)); const bubbleW = Math.floor(SCREEN_W * 0.60); return ( {barHeights.map((h, i) => ( ))} {displayDuration} ); } 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; /** Clean-Background-Mode (weißer Chat-BG): eingehende Bubbles bekommen einen * leichten Grauton statt Weiß, damit sie sich vom Hintergrund abheben (Insta-Style). */ cleanBg?: boolean; }; 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', // Eigene Voice-Bubble: kräftiges Mint-Grün (Insta-Style), weißer Inhalt. ownAudioBg: isDark ? '#1f7a63' : '#34C7A0', ownText: isDark ? '#e8f5e2' : '#0a0a0a', otherBg: isDark ? '#2c2c2e' : '#ffffff', otherAudioBg: isDark ? '#2c2c2e' : '#ffffff', otherText: isDark ? '#ffffff' : '#0a0a0a', replyBarColor: '#25D366', readColor: '#34B7F1', }; } function CallNoteRow({ msg }: { msg: ChatMsg }) { const { t } = useTranslation(); const colors = useColors(); const [, stateRaw, durRaw] = (msg.attachmentName ?? 'audio:ended:0').split(':'); const state = (stateRaw || 'ended') as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy'; const durSec = parseInt(durRaw || '0', 10) || 0; const isMissed = state !== 'ended'; const iconColor = isMissed ? '#ef4444' : (msg.isOwn ? '#10b981' : colors.text); let label: string; if (!isMissed) { const m = Math.floor(durSec / 60); const s = (durSec % 60).toString().padStart(2, '0'); label = `${t('chat.call_audio')} · ${m}:${s}`; } else if (state === 'declined') { label = msg.isOwn ? t('chat.call_declined') : t('chat.call_missed'); } else if (state === 'unanswered') { label = msg.isOwn ? t('chat.call_no_answer') : t('chat.call_missed'); } else { label = t('chat.call_failed'); } const time = new Date(msg.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); return ( {label} {time} ); } export function ChatBubble(props: Props) { // Call-Notiz (System-Row, kein Bubble) — eigenes Render-Path, ohne Hooks-Aufwand. if (props.msg.attachmentType === 'call') { return ; } return ; } function ChatBubbleInner({ msg, showName = false, isFirstInGroup = true, isLastInGroup = true, hideReadStatus = false, isDM = false, onReply, onLike, onReact, onDelete, onOpenImage, cleanBg = false, }: Props) { const { t } = useTranslation(); const colors = useColors(); const styles = makeStyles(colors); const bubbleColors = useBubbleColors(); const bubbleRef = useRef(null); const [menuVisible, setMenuVisible] = useState(false); const [anchor, setAnchor] = useState(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, }; // Clean-Mode + Light: eingehende Bubbles leicht grau (#f0f0f2) statt reinweiß, // sonst verschwinden sie auf dem weißen Clean-Background. Dark-Mode braucht das // nicht (otherBg #2c2c2e hebt sich schon vom schwarzen BG ab). const colorScheme = useThemeStore((s) => s.colorScheme); const otherBgEff = cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherBg; const otherAudioBgEff = cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherAudioBg; const bubbleBg = msg.isOwn ? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg) : (isAudioMsg ? otherAudioBgEff : otherBgEff); 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 = ( {msg.attachmentUrl && msg.attachmentType === 'image' ? ( ) : msg.attachmentUrl && msg.attachmentType === 'audio' ? ( ) : msg.content !== '' ? ( {msg.content} ) : null} ); return ( <> {!msg.isOwn && ( {isLastInGroup ? ( ) : null} )} {showName && !msg.isOwn && isFirstInGroup && ( {msg.nickname ?? '?'} )} {msg.replyTo && ( {msg.replyTo.nickname ?? '?'} {replyHasImage && ( )} {replyHasAudio && ( )}{' '} {msg.replyTo.content || (replyHasImage ? t('chat.image_attachment') : replyHasAudio ? `🎤 ${t('chat.voice_message')}` : '…')} )} {msg.attachmentUrl && msg.attachmentType === 'image' && ( onOpenImage(msg.attachmentUrl!)} activeOpacity={0.7} style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]} > {isImageOnly && ( {!isDM && msg.likesCount > 0 && ( {msg.likesCount} )} {formatTime(msg.createdAt)} )} )} {msg.attachmentUrl && msg.attachmentType === 'audio' && ( )} {msg.attachmentUrl && msg.attachmentType !== 'image' && msg.attachmentType !== 'audio' && ( {msg.attachmentName ?? t('chat.file_attachment')} )} {msg.deleted ? ( {t('chat.message_deleted')} ) : msg.content !== '' ? ( {msg.content} ) : null} {!isImageOnly && ( {!isDM && msg.likesCount > 0 && ( {msg.likesCount} )} {formatTime(msg.createdAt)} {isDM && msg.isOwn && msg.status !== 'pending' && msg.status !== 'failed' && ( )} {msg.status === 'pending' && ( )} {msg.status === 'failed' && ( )} )} {/* Insta-Style: kleines Herz-Badge hängt unter der Bubble (nur DM, nur wenn liked) */} {isDM && msg.likedByMe && ( 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, }, ]} > )} {/* Emoji-Reaktions-Pills unter der Bubble (DM) */} {msg.reactions && msg.reactions.length > 0 && ( {msg.reactions.map((r) => ( onReact?.(msg, r.emoji)} activeOpacity={0.6} style={styles.reactionPill} > {r.emoji} {r.count > 1 && {r.count}} ))} )} setMenuVisible(false)} onReact={(emoji) => onReact?.(msg, emoji)} onReply={() => onReply(msg)} onCopy={copyContent} onDelete={() => onDelete?.(msg)} /> ); } function makeStyles(colors: ReturnType) { 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: 15, lineHeight: 22, 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, }, }); }