import { chat as chatNs } from '@chat-app/shared'; import type { DecryptedMessage, MessageReaction } from '@chat-app/shared/chat'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../theme/colors'; import { AttachmentImage } from './AttachmentImage'; import { ReactionPills } from './ReactionPills'; interface Props { message: DecryptedMessage; mine: boolean; senderName: string; time: string; parent: DecryptedMessage | null; parentSenderName: string; reactions: MessageReaction[]; myUserId: string | null; ownDeviceId: string | null; ownPrivateKey: Uint8Array | null; onLongPress: () => void; onToggleReaction: (emoji: string) => void; } // Single message bubble. Renders, in order: // * Reply quote (when this message has a replyToId). // * Body text (or "Nachricht gelöscht" placeholder). // * Image attachment (max 1 in Phase 2 — first one wins). // * Reaction pills. // Long-press surfaces the MessageActionsSheet via the onLongPress callback. export function MessageBubble({ message, mine, senderName, time, parent, parentSenderName, reactions, myUserId, ownDeviceId, ownPrivateKey, onLongPress, onToggleReaction, }: Props) { const deleted = message.deletedAt !== null; const parsed = chatNs.parseMessagePayload(message.plaintext); const text = parsed.kind === 'text' ? parsed.text : ''; const attachments = parsed.kind === 'text' ? parsed.attachments : []; const firstImage = attachments.find((a) => a.mimeType.startsWith('image/')); return ( {!mine && !deleted && {senderName}} {message.replyToId && ( {parent ? parentSenderName : 'Original'} {parent ? quotedPreview(parent) : '↩ Original-Nachricht außerhalb dieses Fensters'} )} {deleted ? ( Nachricht gelöscht ) : ( <> {text.length > 0 && {text}} {firstImage && ownDeviceId && ownPrivateKey && ( )} )} {time} {!deleted && ( )} ); } function quotedPreview(m: DecryptedMessage): string { if (m.deletedAt) return '[gelöscht]'; const parsed = chatNs.parseMessagePayload(m.plaintext); if (parsed.kind === 'text') { if (parsed.text.length > 0) return parsed.text; if (parsed.attachments.length > 0) return '📎 Anhang'; } return '…'; } const styles = StyleSheet.create({ wrap: { marginVertical: 4, maxWidth: '78%' }, wrapMine: { alignSelf: 'flex-end', alignItems: 'flex-end' }, wrapOther: { alignSelf: 'flex-start', alignItems: 'flex-start' }, bubble: { padding: 10, borderRadius: 14 }, bubbleMine: { backgroundColor: colors.accent }, bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 }, sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 }, body: { color: colors.text, fontSize: 15, lineHeight: 20 }, time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' }, deleted: { color: colors.textMuted, fontSize: 14, fontStyle: 'italic' }, replyQuote: { borderLeftWidth: 3, borderLeftColor: colors.accent, paddingLeft: 8, paddingVertical: 4, marginBottom: 6, backgroundColor: colors.bg, borderRadius: 4, }, replyQuoteMine: { backgroundColor: 'rgba(0,0,0,0.18)', borderLeftColor: colors.text }, replyAuthor: { color: colors.textMuted, fontSize: 11, fontWeight: '700' }, replyBody: { color: colors.text, fontSize: 13, opacity: 0.85 }, });