chahinebrini 8670b45351 fix(magic): inline mobileconfig template as TS constant
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.
2026-06-03 09:57:27 +02:00

714 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});
}