127 lines
4.2 KiB
TypeScript
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 },
|
|
});
|