serverAssets approach didn't bundle the template into the Nitro output (no .output-staging/server/chunks/raw/ dir, no asset-storage mount in nitro.mjs). Logs confirm: '[Magic] Profile template missing in serverAssets'. Drop serverAssets entirely. Inline the template (~2KB) as a TS constant in backend/server/utils/magic-profile-template.ts. Build- robust, no FS/storage dependency at runtime. Canonical source of truth remains ops/mdm/rebreak-mac-dns-filter.mobileconfig — keep in sync manually until/unless we add a codegen step.
714 lines
24 KiB
TypeScript
714 lines
24 KiB
TypeScript
import { useRef, useState, useEffect, useMemo } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
Dimensions,
|
||
} from 'react-native';
|
||
import { Image } from 'expo-image';
|
||
import * as Clipboard from 'expo-clipboard';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { Audio } from 'expo-av';
|
||
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';
|
||
|
||
function fmtSec(s: number): string {
|
||
const m = Math.floor(s / 60);
|
||
const sec = (s % 60).toString().padStart(2, '0');
|
||
return `${m}:${sec}`;
|
||
}
|
||
|
||
const SCREEN_W = Dimensions.get('window').width;
|
||
|
||
function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: string; isOwn: boolean }) {
|
||
const colors = useColors();
|
||
const bubbleColors = useBubbleColors();
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [waveWidth, setWaveWidth] = useState(0);
|
||
const soundRef = useRef<Audio.Sound | null>(null);
|
||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
// Merkt sich ob die Wiedergabe komplett durchlief — dann muss der nächste
|
||
// Play von vorne (replayAsync) statt am Ende-stehengebliebenen playAsync.
|
||
const finishedRef = useRef(false);
|
||
|
||
const totalSeconds = useMemo(() => {
|
||
const [m, s] = (duration ?? '0:00').split(':').map(Number);
|
||
return (m || 0) * 60 + (s || 0);
|
||
}, [duration]);
|
||
|
||
// WhatsApp-Look: ~34 dickere Balken mit deutlich variierender Höhe statt
|
||
// 80 gleichförmiger dünner Striche (sah „hardcodiert" aus). Deterministischer
|
||
// LCG-PRNG (aus URL geseedet) → pro Sprachnachricht stabil, aber natürliche
|
||
// Amplituden-Streuung wie eine echte Sprach-Wellenform.
|
||
const barHeights = useMemo(() => {
|
||
let s = url.split('').reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) >>> 0, 7) || 1;
|
||
const rand = () => {
|
||
s = (s * 1103515245 + 12345) >>> 0;
|
||
return s / 0xffffffff;
|
||
};
|
||
const MAX_H = 24;
|
||
return Array.from({ length: 34 }, (_, i) => {
|
||
// Sanfte Sprech-Hüllkurve (steigt/fällt) × Zufalls-Spitzen
|
||
const env = 0.45 + 0.55 * Math.abs(Math.sin((i / 34) * Math.PI * 2.3 + (s % 5)));
|
||
const peak = 0.2 + 0.8 * rand();
|
||
return Math.max(3, peak * env * MAX_H);
|
||
});
|
||
}, [url]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (pollRef.current) clearInterval(pollRef.current);
|
||
soundRef.current?.unloadAsync();
|
||
};
|
||
}, []);
|
||
|
||
async function togglePlay() {
|
||
if (isPlaying) {
|
||
await soundRef.current?.pauseAsync();
|
||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
try {
|
||
if (!soundRef.current) {
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true });
|
||
const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true });
|
||
soundRef.current = sound;
|
||
finishedRef.current = false;
|
||
sound.setOnPlaybackStatusUpdate((s) => {
|
||
if (s.isLoaded && s.didJustFinish) {
|
||
finishedRef.current = true;
|
||
setIsPlaying(false);
|
||
setProgress(0);
|
||
setCurrentTime(0);
|
||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||
}
|
||
});
|
||
} else if (finishedRef.current) {
|
||
// Nach komplettem Durchlauf: von vorne abspielen (Position steht am Ende)
|
||
finishedRef.current = false;
|
||
setProgress(0);
|
||
setCurrentTime(0);
|
||
await soundRef.current.replayAsync();
|
||
} else {
|
||
// Resume nach Pause: Position beibehalten
|
||
await soundRef.current.playAsync();
|
||
}
|
||
setIsPlaying(true);
|
||
pollRef.current = setInterval(async () => {
|
||
const s = await soundRef.current?.getStatusAsync();
|
||
if (s?.isLoaded) {
|
||
setCurrentTime(Math.floor(s.positionMillis / 1000));
|
||
setProgress(s.durationMillis ? s.positionMillis / s.durationMillis : 0);
|
||
}
|
||
}, 100);
|
||
} catch (err) {
|
||
console.warn('[VoiceNote] play error:', err);
|
||
}
|
||
}
|
||
|
||
const playedCount = Math.floor(progress * barHeights.length);
|
||
const DOT_SIZE = 7;
|
||
const dotLeft = waveWidth > 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0;
|
||
|
||
const playBtnBg = isOwn ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.06)';
|
||
const playIconColor = isOwn ? bubbleColors.ownText : colors.text;
|
||
// WA-Stil: Bars immer dunkelgrau/schwarz — unabhängig von own/other
|
||
const playedBarColor = 'rgba(0,0,0,0.62)';
|
||
const unplayedBarColor = 'rgba(0,0,0,0.18)';
|
||
const dotColor = '#007AFF';
|
||
const durationColor = isOwn ? bubbleColors.ownText + '99' : colors.textMuted;
|
||
const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
|
||
|
||
const bubbleW = Math.floor(SCREEN_W * 0.60);
|
||
|
||
return (
|
||
<View style={{ width: bubbleW, paddingVertical: 2, paddingHorizontal: 0 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||
<TouchableOpacity onPress={togglePlay} activeOpacity={0.7} hitSlop={8}>
|
||
<View style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: playBtnBg, alignItems: 'center', justifyContent: 'center' }}>
|
||
<Ionicons name={isPlaying ? 'pause' : 'play'} size={13} color={playIconColor} style={{ marginLeft: isPlaying ? 0 : 1 }} />
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
<View
|
||
style={{ flex: 1, height: 26, position: 'relative' }}
|
||
onLayout={(e) => setWaveWidth(e.nativeEvent.layout.width)}
|
||
>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
|
||
{barHeights.map((h, i) => (
|
||
<View
|
||
key={i}
|
||
style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
|
||
/>
|
||
))}
|
||
</View>
|
||
|
||
{waveWidth > 0 && (
|
||
<View style={{
|
||
position: 'absolute',
|
||
top: '50%',
|
||
marginTop: -(DOT_SIZE / 2),
|
||
left: dotLeft,
|
||
width: DOT_SIZE,
|
||
height: DOT_SIZE,
|
||
borderRadius: DOT_SIZE / 2,
|
||
backgroundColor: dotColor,
|
||
}} />
|
||
)}
|
||
</View>
|
||
</View>
|
||
|
||
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: durationColor, marginTop: 2, marginLeft: 36, fontVariant: ['tabular-nums'] }}>
|
||
{displayDuration}
|
||
</Text>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
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',
|
||
ownAudioBg: isDark ? '#1a4430' : '#C2EDBA',
|
||
ownText: isDark ? '#e8f5e2' : '#0a0a0a',
|
||
otherBg: isDark ? '#2c2c2e' : '#ffffff',
|
||
otherAudioBg: 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;
|
||
// isAudioMsg: hat Audio-Attachment (unabhängig von replyTo/content — für Styling)
|
||
const isAudioMsg = !!msg.attachmentUrl && msg.attachmentType === 'audio';
|
||
const isAudioOnly = isAudioMsg && !msg.content && !msg.replyTo;
|
||
const replyHasImage = msg.replyTo?.attachmentType === 'image';
|
||
const replyHasAudio = msg.replyTo?.attachmentType === 'audio';
|
||
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
|
||
? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg)
|
||
: (isAudioMsg ? bubbleColors.otherAudioBg : 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 },
|
||
isAudioMsg && { paddingHorizontal: 6, paddingTop: 6, paddingBottom: 4 },
|
||
]}
|
||
>
|
||
{msg.attachmentUrl && msg.attachmentType === 'image' ? (
|
||
<Image source={{ uri: msg.attachmentUrl }} style={styles.image} contentFit="cover" cachePolicy="memory-disk" />
|
||
) : msg.attachmentUrl && msg.attachmentType === 'audio' ? (
|
||
<VoiceNoteBubble url={msg.attachmentUrl} duration={msg.attachmentName ?? '0:00'} isOwn={msg.isOwn} />
|
||
) : 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' }, isAudioMsg && styles.bubbleColAudio]}>
|
||
{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 },
|
||
isAudioMsg && { paddingHorizontal: 6, paddingTop: 6, paddingBottom: 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}
|
||
>
|
||
{replyHasImage && (
|
||
<Ionicons name="image" size={11} color={colors.textMuted} />
|
||
)}
|
||
{replyHasAudio && (
|
||
<Ionicons name="mic" size={11} color={colors.textMuted} />
|
||
)}{' '}
|
||
{msg.replyTo.content || (replyHasImage ? t('chat.image_attachment') : replyHasAudio ? `🎤 ${t('chat.voice_message')}` : '…')}
|
||
</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 === 'audio' && (
|
||
<VoiceNoteBubble
|
||
url={msg.attachmentUrl}
|
||
duration={msg.attachmentName ?? '0:00'}
|
||
isOwn={msg.isOwn}
|
||
/>
|
||
)}
|
||
|
||
{msg.attachmentUrl && msg.attachmentType !== 'image' && msg.attachmentType !== 'audio' && (
|
||
<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%',
|
||
},
|
||
bubbleColAudio: {
|
||
maxWidth: '84%',
|
||
},
|
||
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: 15,
|
||
lineHeight: 22,
|
||
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,
|
||
},
|
||
});
|
||
}
|