530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
import { useRef, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
} from 'react-native';
|
|
import { Image } from 'expo-image';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useColors } from '../../lib/theme';
|
|
import { useThemeStore } from '../../stores/theme';
|
|
import { UserAvatar } from '../UserAvatar';
|
|
import { MessageActionMenu, type AnchorRect } from './MessageActionMenu';
|
|
|
|
export type MessageReaction = { emoji: string; count: number; mine: boolean };
|
|
|
|
export type ChatMsg = {
|
|
id: string;
|
|
userId: string;
|
|
nickname?: string | null;
|
|
avatar?: string | null;
|
|
content: string;
|
|
replyTo?: {
|
|
id: string;
|
|
userId: string;
|
|
nickname?: string | null;
|
|
content: string;
|
|
attachmentType?: string | null;
|
|
} | null;
|
|
attachmentUrl?: string | null;
|
|
attachmentType?: string | null;
|
|
attachmentName?: string | null;
|
|
likesCount: number;
|
|
likedByMe?: boolean;
|
|
createdAt: string;
|
|
isOwn: boolean;
|
|
readAt?: string | null;
|
|
/** Aggregierte Emoji-Reaktionen (DM). */
|
|
reactions?: MessageReaction[];
|
|
/** Soft-Delete-Tombstone. */
|
|
deleted?: boolean;
|
|
/** Optimistic-UI Status (pending = wird gesendet, failed = Fehler). */
|
|
status?: 'pending' | 'sent' | 'failed';
|
|
};
|
|
|
|
type Props = {
|
|
msg: ChatMsg;
|
|
showName?: boolean;
|
|
isFirstInGroup?: boolean;
|
|
isLastInGroup?: boolean;
|
|
hideReadStatus?: boolean;
|
|
/** Direct-Message-Mode: Likes als boolean-Herz (Insta-Style) statt Count, kein Avatar-Spalte-Whatever */
|
|
isDM?: boolean;
|
|
onReply: (msg: ChatMsg) => void;
|
|
onLike: (msg: ChatMsg) => void;
|
|
/** DM-only: Emoji-Reaktion togglen. Group-Chat (deaktiviert) übergibt nichts. */
|
|
onReact?: (msg: ChatMsg, emoji: string) => void;
|
|
/** DM-only: eigene Nachricht löschen (Soft-Delete). */
|
|
onDelete?: (msg: ChatMsg) => void;
|
|
onOpenImage: (url: string) => void;
|
|
};
|
|
|
|
function formatTime(ts: string) {
|
|
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function useBubbleColors() {
|
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
|
const isDark = colorScheme === 'dark';
|
|
return {
|
|
ownBg: isDark ? '#1e4d3a' : '#D1F4CC',
|
|
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
|
|
otherBg: isDark ? '#2c2c2e' : '#ffffff',
|
|
otherText: isDark ? '#ffffff' : '#0a0a0a',
|
|
replyBarColor: '#25D366',
|
|
readColor: '#34B7F1',
|
|
};
|
|
}
|
|
|
|
export function ChatBubble({
|
|
msg,
|
|
showName = false,
|
|
isFirstInGroup = true,
|
|
isLastInGroup = true,
|
|
hideReadStatus = false,
|
|
isDM = false,
|
|
onReply,
|
|
onLike,
|
|
onReact,
|
|
onDelete,
|
|
onOpenImage,
|
|
}: Props) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const bubbleColors = useBubbleColors();
|
|
const bubbleRef = useRef<View>(null);
|
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
const [anchor, setAnchor] = useState<AnchorRect | null>(null);
|
|
const hasContent = msg.content !== '';
|
|
const myReaction = msg.reactions?.find((r) => r.mine)?.emoji ?? null;
|
|
|
|
function openActions() {
|
|
if (msg.deleted) return; // gelöschte Nachrichten: kein Kontextmenü
|
|
bubbleRef.current?.measureInWindow((x, y, width, height) => {
|
|
setAnchor({ x, y, width, height });
|
|
setMenuVisible(true);
|
|
});
|
|
}
|
|
|
|
const isImageOnly =
|
|
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
|
|
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
|
|
const ownBubbleRadius = {
|
|
borderTopLeftRadius: 14,
|
|
borderTopRightRadius: isFirstInGroup ? 14 : 4,
|
|
borderBottomLeftRadius: 14,
|
|
borderBottomRightRadius: isLastInGroup ? 4 : 4,
|
|
};
|
|
|
|
const otherBubbleRadius = {
|
|
borderTopLeftRadius: isFirstInGroup ? 14 : 4,
|
|
borderTopRightRadius: 14,
|
|
borderBottomLeftRadius: isLastInGroup ? 4 : 4,
|
|
borderBottomRightRadius: 14,
|
|
};
|
|
|
|
const bubbleBg = msg.isOwn ? bubbleColors.ownBg : bubbleColors.otherBg;
|
|
const bubbleText = msg.isOwn ? bubbleColors.ownText : bubbleColors.otherText;
|
|
|
|
function copyContent() {
|
|
if (msg.content) Clipboard.setStringAsync(msg.content);
|
|
}
|
|
|
|
// Scharfe Kopie der Bubble fürs Kontextmenü (bleibt über dem Blur sichtbar).
|
|
const previewNode = (
|
|
<View
|
|
style={[
|
|
styles.bubble,
|
|
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
|
|
{ backgroundColor: bubbleBg },
|
|
!msg.isOwn && styles.bubbleOtherBorder,
|
|
isImageOnly && { padding: 4 },
|
|
]}
|
|
>
|
|
{msg.attachmentUrl && msg.attachmentType === 'image' ? (
|
|
<Image source={{ uri: msg.attachmentUrl }} style={styles.image} contentFit="cover" />
|
|
) : msg.content !== '' ? (
|
|
<Text style={[styles.content, { color: bubbleText }]}>{msg.content}</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<View
|
|
style={[
|
|
styles.row,
|
|
{ justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' },
|
|
{ marginTop: isFirstInGroup ? 8 : 2 },
|
|
]}
|
|
>
|
|
{!msg.isOwn && (
|
|
<View style={styles.avatarSlot}>
|
|
{isLastInGroup ? (
|
|
<UserAvatar
|
|
userId={msg.userId}
|
|
avatar={msg.avatar ?? null}
|
|
nickname={msg.nickname ?? '?'}
|
|
size="sm"
|
|
showOnlineIndicator={false}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
|
|
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
|
|
{showName && !msg.isOwn && isFirstInGroup && (
|
|
<Text style={styles.nickname} numberOfLines={1}>
|
|
{msg.nickname ?? '?'}
|
|
</Text>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
ref={bubbleRef}
|
|
delayLongPress={350}
|
|
onLongPress={openActions}
|
|
activeOpacity={1}
|
|
style={[
|
|
styles.bubble,
|
|
msg.isOwn ? ownBubbleRadius : otherBubbleRadius,
|
|
{ backgroundColor: bubbleBg },
|
|
!msg.isOwn && styles.bubbleOtherBorder,
|
|
isImageOnly && { padding: 4 },
|
|
msg.status === 'pending' && { opacity: 0.6 },
|
|
msg.status === 'failed' && { borderWidth: 1, borderColor: '#ef4444' },
|
|
]}
|
|
>
|
|
{msg.replyTo && (
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
style={[
|
|
styles.replyPreview,
|
|
{
|
|
backgroundColor: msg.isOwn
|
|
? 'rgba(0,0,0,0.08)'
|
|
: colors.surfaceElevated,
|
|
borderLeftColor: bubbleColors.replyBarColor,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: bubbleColors.replyBarColor,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{msg.replyTo.nickname ?? '?'}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
marginTop: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{replyHasAttachment && (
|
|
<Ionicons name="image" size={11} color={colors.textMuted} />
|
|
)}{' '}
|
|
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{msg.attachmentUrl && msg.attachmentType === 'image' && (
|
|
<TouchableOpacity
|
|
onPress={() => onOpenImage(msg.attachmentUrl!)}
|
|
activeOpacity={0.7}
|
|
style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]}
|
|
>
|
|
<Image
|
|
source={{ uri: msg.attachmentUrl }}
|
|
style={styles.image}
|
|
contentFit="cover"
|
|
cachePolicy="memory-disk"
|
|
transition={200}
|
|
/>
|
|
{isImageOnly && (
|
|
<View style={styles.imageTimeOverlay}>
|
|
{!isDM && msg.likesCount > 0 && (
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
|
|
<Ionicons name="heart" size={10} color="#f87171" />
|
|
<Text style={{ fontSize: 10, color: '#fff', marginLeft: 2 }}>
|
|
{msg.likesCount}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<Text style={{ fontSize: 10, color: '#fff' }}>{formatTime(msg.createdAt)}</Text>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
borderRadius: 10,
|
|
marginBottom: 4,
|
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
|
}}
|
|
>
|
|
<Ionicons name="document-attach" size={18} color={colors.textMuted} />
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
marginLeft: 8,
|
|
color: bubbleText,
|
|
flex: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{msg.attachmentName ?? t('chat.file_attachment')}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{msg.deleted ? (
|
|
<Text style={[styles.content, { color: bubbleText, fontStyle: 'italic', opacity: 0.6 }]}>
|
|
{t('chat.message_deleted')}
|
|
</Text>
|
|
) : msg.content !== '' ? (
|
|
<Text style={[styles.content, { color: bubbleText }]}>
|
|
{msg.content}
|
|
</Text>
|
|
) : null}
|
|
|
|
{!isImageOnly && (
|
|
<View style={styles.footer}>
|
|
{!isDM && msg.likesCount > 0 && (
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
|
|
<Ionicons name="heart" size={10} color="#f87171" />
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
marginLeft: 2,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
}}
|
|
>
|
|
{msg.likesCount}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<Text
|
|
style={{
|
|
fontSize: 10,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: msg.isOwn ? 'rgba(0,0,0,0.45)' : colors.textMuted,
|
|
}}
|
|
>
|
|
{formatTime(msg.createdAt)}
|
|
</Text>
|
|
{isDM && msg.isOwn && msg.status !== 'pending' && msg.status !== 'failed' && (
|
|
<Ionicons
|
|
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
|
|
size={12}
|
|
color={msg.readAt ? bubbleColors.readColor : 'rgba(0,0,0,0.35)'}
|
|
style={{ marginLeft: 2 }}
|
|
/>
|
|
)}
|
|
{msg.status === 'pending' && (
|
|
<Ionicons
|
|
name="time-outline"
|
|
size={11}
|
|
color="rgba(0,0,0,0.35)"
|
|
style={{ marginLeft: 2 }}
|
|
/>
|
|
)}
|
|
{msg.status === 'failed' && (
|
|
<Ionicons
|
|
name="alert-circle"
|
|
size={11}
|
|
color="#ef4444"
|
|
style={{ marginLeft: 2 }}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{/* Insta-Style: kleines Herz-Badge hängt unter der Bubble (nur DM, nur wenn liked) */}
|
|
{isDM && msg.likedByMe && (
|
|
<TouchableOpacity
|
|
onPress={() => onLike(msg)}
|
|
activeOpacity={0.7}
|
|
hitSlop={8}
|
|
style={[
|
|
styles.dmHeartBadge,
|
|
{
|
|
alignSelf: msg.isOwn ? 'flex-end' : 'flex-start',
|
|
marginRight: msg.isOwn ? 8 : 0,
|
|
marginLeft: msg.isOwn ? 0 : 8,
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons name="heart" size={12} color="#f87171" />
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{/* Emoji-Reaktions-Pills unter der Bubble (DM) */}
|
|
{msg.reactions && msg.reactions.length > 0 && (
|
|
<View
|
|
style={[
|
|
styles.reactionPills,
|
|
{ alignSelf: msg.isOwn ? 'flex-end' : 'flex-start' },
|
|
]}
|
|
>
|
|
{msg.reactions.map((r) => (
|
|
<TouchableOpacity
|
|
key={r.emoji}
|
|
onPress={() => onReact?.(msg, r.emoji)}
|
|
activeOpacity={0.6}
|
|
style={styles.reactionPill}
|
|
>
|
|
<Text style={{ fontSize: 16 }}>{r.emoji}</Text>
|
|
{r.count > 1 && <Text style={styles.reactionPillCount}>{r.count}</Text>}
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<MessageActionMenu
|
|
visible={menuVisible}
|
|
anchor={anchor}
|
|
isOwn={msg.isOwn}
|
|
hasContent={hasContent}
|
|
myReaction={myReaction}
|
|
preview={previewNode}
|
|
onClose={() => setMenuVisible(false)}
|
|
onReact={(emoji) => onReact?.(msg, emoji)}
|
|
onReply={() => onReply(msg)}
|
|
onCopy={copyContent}
|
|
onDelete={() => onDelete?.(msg)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
row: {
|
|
flexDirection: 'row',
|
|
paddingHorizontal: 10,
|
|
},
|
|
avatarSlot: {
|
|
width: 32,
|
|
marginRight: 6,
|
|
justifyContent: 'flex-end',
|
|
},
|
|
reactionPills: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
gap: 4,
|
|
marginTop: -6,
|
|
marginBottom: 2,
|
|
},
|
|
reactionPill: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 2,
|
|
},
|
|
reactionPillCount: {
|
|
fontSize: 11,
|
|
marginLeft: 3,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
},
|
|
bubbleCol: {
|
|
maxWidth: '76%',
|
|
},
|
|
nickname: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#25D366',
|
|
marginBottom: 3,
|
|
marginLeft: 12,
|
|
},
|
|
bubble: {
|
|
paddingHorizontal: 12,
|
|
paddingTop: 8,
|
|
paddingBottom: 6,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 3,
|
|
shadowOffset: { width: 0, height: 1 },
|
|
elevation: 1,
|
|
},
|
|
bubbleOtherBorder: {
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: 'rgba(0,0,0,0.06)',
|
|
},
|
|
replyPreview: {
|
|
borderLeftWidth: 4,
|
|
borderRadius: 6,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
marginBottom: 6,
|
|
},
|
|
imageWrap: {
|
|
borderRadius: 10,
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
},
|
|
image: {
|
|
width: 220,
|
|
height: 220,
|
|
backgroundColor: colors.surfaceElevated,
|
|
},
|
|
imageTimeOverlay: {
|
|
position: 'absolute',
|
|
bottom: 6,
|
|
right: 6,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
borderRadius: 10,
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
content: {
|
|
fontSize: 14,
|
|
lineHeight: 21,
|
|
fontFamily: 'Nunito_400Regular',
|
|
},
|
|
footer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
gap: 3,
|
|
marginTop: 4,
|
|
alignSelf: 'flex-end',
|
|
},
|
|
dmHeartBadge: {
|
|
marginTop: -6,
|
|
backgroundColor: colors.bg,
|
|
borderRadius: 999,
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 3,
|
|
shadowColor: '#000',
|
|
shadowOpacity: 0.12,
|
|
shadowOffset: { width: 0, height: 1 },
|
|
shadowRadius: 2,
|
|
elevation: 2,
|
|
},
|
|
});
|
|
}
|