chahinebrini 50425a62ee fix(devices): Magic-Hub zeigt jetzt alle Native-Geraete, Native dedupliziert Mac
Magic-Mac-Hub (/api/magic/devices):
- Filter boundToPlan war zu eng \u2014 iPhone/iPad ohne aktiven Plan-Lock
  fielen raus. Jetzt: alle UserDevice-Rows des Users ausser den
  magic-enrolled, plus ProtectedDevice mit Dedupe.

Native /devices Page:
- MacBook erschien doppelt: einmal als UserDevice (registriert via
  Magic-Mac, model=Mac14,9) und einmal als ProtectedDevice (alter
  DNS-Flow). Dedupe per platform-key (mac/ios/android/win):
  wenn UserDevice mit gleicher Plattform existiert, blende
  ProtectedDevice aus.
- Slot-Counter zaehlt jetzt nach dedupe (totalRegistered).
2026-06-03 19:43:33 +02:00

735 lines
25 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;
// Insta-Style Wellenform-Farben:
// - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte
// halbtransparent-weiß (grau wirkend).
// - fremde Bubble (graue Clean-Bubble): Inhalt schwarz, gespielte Bars
// schwarz, ungespielte grau.
// Im Ruhezustand (noch nicht gestartet ODER nach komplettem Durchlauf) sind
// ALLE Bars in der Vollfarbe (schwarz / weiß) — die Zwei-Ton-Progress-Ansicht
// erscheint nur während/nach dem Abspielen.
const fullBarColor = isOwn ? '#ffffff' : '#0a0a0a';
const dimBarColor = isOwn ? 'rgba(255,255,255,0.42)' : 'rgba(0,0,0,0.26)';
const showFullBars = !isPlaying && progress === 0;
const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)';
const playIconColor = isOwn ? '#ffffff' : colors.text;
const dotColor = '#007AFF';
const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : 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: showFullBars ? fullBarColor : (i < playedCount ? fullBarColor : dimBarColor) }}
/>
))}
</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;
/** Clean-Background-Mode (weißer Chat-BG): eingehende Bubbles bekommen einen
* leichten Grauton statt Weiß, damit sie sich vom Hintergrund abheben (Insta-Style). */
cleanBg?: boolean;
};
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',
// Eigene Voice-Bubble: kräftiges Mint-Grün (Insta-Style), weißer Inhalt.
ownAudioBg: isDark ? '#1f7a63' : '#34C7A0',
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,
cleanBg = false,
}: 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,
};
// Clean-Mode + Light: eingehende Bubbles leicht grau (#f0f0f2) statt reinweiß,
// sonst verschwinden sie auf dem weißen Clean-Background. Dark-Mode braucht das
// nicht (otherBg #2c2c2e hebt sich schon vom schwarzen BG ab).
const colorScheme = useThemeStore((s) => s.colorScheme);
const otherBgEff =
cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherBg;
const otherAudioBgEff =
cleanBg && colorScheme !== 'dark' ? '#EFEFF1' : bubbleColors.otherAudioBg;
const bubbleBg = msg.isOwn
? (isAudioMsg ? bubbleColors.ownAudioBg : bubbleColors.ownBg)
: (isAudioMsg ? otherAudioBgEff : otherBgEff);
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,
},
});
}