import { useState, useRef } from 'react'; import { View, Text, Pressable, TouchableOpacity, Image, StyleSheet, Modal, Alert, Platform, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { resolveAvatar } from '../../lib/resolveAvatar'; import { useColors } from '../../lib/theme'; 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; }; type Props = { msg: ChatMsg; showName?: boolean; isFirstInGroup?: boolean; isLastInGroup?: boolean; hideReadStatus?: boolean; onReply: (msg: ChatMsg) => void; onLike: (msg: ChatMsg) => void; onOpenImage: (url: string) => void; }; function formatTime(ts: string) { return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); } export function ChatBubble({ msg, showName = false, isFirstInGroup = true, isLastInGroup = true, hideReadStatus = false, onReply, onLike, onOpenImage, }: Props) { const { t } = useTranslation(); const colors = useColors(); const styles = makeStyles(colors); const [actionsOpen, setActionsOpen] = useState(false); const longPressTimer = useRef | null>(null); const isImageOnly = !!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo; const replyHasAttachment = msg.replyTo?.attachmentType === 'image'; const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?'); const cornerStyle = msg.isOwn ? isLastInGroup ? { borderBottomRightRadius: 6 } : { borderTopRightRadius: 6, borderBottomRightRadius: 6 } : isLastInGroup ? { borderBottomLeftRadius: 6 } : { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }; function copyContent() { if (msg.content) Clipboard.setStringAsync(msg.content); setActionsOpen(false); } return ( <> {/* Avatar slot left (last of group, not own) */} {!msg.isOwn && ( {isLastInGroup ? ( ) : null} )} {showName && !msg.isOwn && isFirstInGroup && ( {msg.nickname ?? '?'} )} setActionsOpen(true)} onPress={() => { /* tap eats - keeps long-press primary */ }} style={[ styles.bubble, msg.isOwn ? styles.bubbleOwn : styles.bubbleOther, cornerStyle, isImageOnly && { padding: 4 }, ]} > {/* Reply preview */} {msg.replyTo && ( { /* could implement scroll-to */ }} activeOpacity={0.7} style={[ styles.replyPreview, { backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5', borderLeftColor: msg.isOwn ? '#fff' : '#007AFF', }, ]} > {msg.replyTo.nickname ?? '?'} {replyHasAttachment && ( )}{' '} {msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')} )} {/* Image attachment */} {msg.attachmentUrl && msg.attachmentType === 'image' && ( onOpenImage(msg.attachmentUrl!)} activeOpacity={0.7} style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]} > {isImageOnly && ( {msg.likesCount > 0 && ( {msg.likesCount} )} {formatTime(msg.createdAt)} )} )} {/* File attachment */} {msg.attachmentUrl && msg.attachmentType !== 'image' && ( {msg.attachmentName ?? t('chat.file_attachment')} )} {/* Content */} {msg.content !== '' && ( {msg.content} )} {/* Footer */} {!isImageOnly && ( {msg.likesCount > 0 && ( {msg.likesCount} )} {formatTime(msg.createdAt)} {msg.isOwn && !hideReadStatus && ( )} )} {/* Long-press action sheet */} setActionsOpen(false)} > setActionsOpen(false)} activeOpacity={1}> {}}> { setActionsOpen(false); onReply(msg); }} activeOpacity={0.7} > {t('chat.reply')} { setActionsOpen(false); onLike(msg); }} activeOpacity={0.7} > {msg.likedByMe ? t('chat.unlike') : t('chat.like')} {msg.content !== '' && ( {t('chat.copy')} )} ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ row: { flexDirection: 'row', paddingHorizontal: 8, }, avatarSlot: { width: 30, marginRight: 4, justifyContent: 'flex-end', }, avatar: { width: 26, height: 26, borderRadius: 13, backgroundColor: colors.surfaceElevated, }, bubbleCol: { maxWidth: '78%', }, nickname: { fontSize: 10, fontFamily: 'Nunito_700Bold', color: '#007AFF', marginBottom: 2, marginLeft: 10, }, bubble: { borderRadius: 18, paddingHorizontal: 12, paddingVertical: 6, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 1, shadowOffset: { width: 0, height: 1 }, }, bubbleOwn: { backgroundColor: '#007AFF', }, bubbleOther: { backgroundColor: colors.surface, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.border, }, replyPreview: { borderLeftWidth: 3, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, marginBottom: 4, }, imageWrap: { borderRadius: 12, 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: 20, fontFamily: 'Nunito_400Regular', }, footer: { position: 'absolute', bottom: 4, right: 8, flexDirection: 'row', alignItems: 'center', }, sheetBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end', }, sheet: { backgroundColor: colors.bg, borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 8, paddingBottom: Platform.OS === 'ios' ? 32 : 16, }, sheetGrabber: { width: 36, height: 4, borderRadius: 2, backgroundColor: colors.border, alignSelf: 'center', marginBottom: 10, }, sheetItem: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14, borderRadius: 12, }, sheetText: { fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.text, marginLeft: 12, }, }); }