chahinebrini 822053e11e feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback
Caller/Callee UX:
- lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone)
- stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix
- locales: 'Wird angerufen…' statt 'Ruft an…'

CallKit (iOS) + ConnectionService (Android):
- lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA)
- hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions
- stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten
- app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms

VoIP-PushKit Backend:
- services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip)
- services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback)
- prisma: push_tokens.voip_token Column + Migration 20260604
- api/users/me/push-token: optional voipToken im Body
- Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION

Push-tap routing + cold-start handling:
- app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call

Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
2026-06-04 09:27:13 +02:00

758 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Audio.Sound | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<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' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
{barHeights.map((h, i) => (
<View
key={i}
style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: showFullBars ? fullBarColor : (i < playedCount ? fullBarColor : dimBarColor) }}
/>
))}
</View>
</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;
/** 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 (
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: colors.surfaceElevated, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 16 }}>
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: 'rgba(0,0,0,0.06)', alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="call-outline" size={15} color={iconColor} />
</View>
<View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>{label}</Text>
<Text style={{ fontSize: 11, color: colors.textMuted, marginTop: 1 }}>{time}</Text>
</View>
</View>
</View>
);
}
export function ChatBubble(props: Props) {
// Call-Notiz (System-Row, kein Bubble) — eigenes Render-Path, ohne Hooks-Aufwand.
if (props.msg.attachmentType === 'call') {
return <CallNoteRow msg={props.msg} />;
}
return <ChatBubbleInner {...props} />;
}
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<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,
};
// 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 = (
<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: 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,
},
});
}