feat(mobile): MessageBubble with reply, attachment, reactions, delete state
This commit is contained in:
@@ -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 (
|
||||
<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 && ownDeviceId && ownPrivateKey && (
|
||||
<AttachmentImage
|
||||
handle={firstImage}
|
||||
ownDeviceId={ownDeviceId}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 },
|
||||
});
|
||||
Reference in New Issue
Block a user