From 286836f539afc56f572a1e4168a0cb7bf59ddd97 Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 14 May 2026 00:21:11 +0200 Subject: [PATCH] feat(mobile): MessageBubble with reply, attachment, reactions, delete state --- apps/mobile/components/MessageBubble.tsx | 132 +++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 apps/mobile/components/MessageBubble.tsx diff --git a/apps/mobile/components/MessageBubble.tsx b/apps/mobile/components/MessageBubble.tsx new file mode 100644 index 0000000..669b9b2 --- /dev/null +++ b/apps/mobile/components/MessageBubble.tsx @@ -0,0 +1,132 @@ +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 }, +});