import { useRef, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet, } from 'react-native'; import { Image } from 'expo-image'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; 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'; 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', ownText: isDark ? '#e8f5e2' : '#0a0a0a', otherBg: 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(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; const replyHasAttachment = msg.replyTo?.attachmentType === 'image'; 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 ? bubbleColors.ownBg : 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 = ( {msg.attachmentUrl && msg.attachmentType === 'image' ? ( ) : msg.content !== '' ? ( {msg.content} ) : null} ); return ( <> {!msg.isOwn && ( {isLastInGroup ? ( ) : null} )} {showName && !msg.isOwn && isFirstInGroup && ( {msg.nickname ?? '?'} )} {msg.replyTo && ( {msg.replyTo.nickname ?? '?'} {replyHasAttachment && ( )}{' '} {msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')} )} {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 !== 'image' && ( {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%', }, 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, }, }); }