Files

127 lines
4.2 KiB
TypeScript

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;
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,
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 (
<View style={[styles.wrap, mine ? styles.wrapMine : styles.wrapOther]}>
<Pressable
onLongPress={onLongPress}
delayLongPress={250}
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}
>
{!mine && !deleted && <Text style={styles.sender}>{senderName}</Text>}
{message.replyToId && (
<View style={[styles.replyQuote, mine ? styles.replyQuoteMine : null]}>
<Text style={styles.replyAuthor}>{parent ? parentSenderName : 'Original'}</Text>
<Text style={styles.replyBody} numberOfLines={2}>
{parent
? quotedPreview(parent)
: '↩ Original-Nachricht außerhalb dieses Fensters'}
</Text>
</View>
)}
{deleted ? (
<Text style={styles.deleted}>Nachricht gelöscht</Text>
) : (
<>
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
{firstImage && ownPrivateKey && (
<AttachmentImage handle={firstImage} />
)}
</>
)}
<Text style={styles.time}>{time}</Text>
</Pressable>
{!deleted && (
<ReactionPills
reactions={reactions}
myUserId={myUserId}
onToggle={onToggleReaction}
/>
)}
</View>
);
}
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 },
});