diff --git a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift index ace8e03..8abc2bf 100644 --- a/apps/rebreak-magic-mac/Sources/Views/LoginView.swift +++ b/apps/rebreak-magic-mac/Sources/Views/LoginView.swift @@ -133,10 +133,28 @@ struct LoginView: View { // MARK: - Logic private func handleDigitInput(_ raw: String, at index: Int) { - // Erlaubt: 0–9. Mehrere Zeichen (Paste) → über alle Felder verteilen. + // Erlaubt: 0–9. Mehrere Zeichen → kann Paste sein ODER User tippt in + // ein bereits gefülltes Feld (newValue = "alt+neu"). let onlyDigits = raw.filter(\.isNumber) + let previous = digits[index] - if onlyDigits.count > 1 { + // Paste-Heuristik: 2+ Ziffern UND keine davon ist die alte Ziffer am Anfang, + // oder Länge > 2. Sonst: User hat in ein gefülltes Feld eine neue Ziffer + // getippt → letzte Ziffer als neuen Wert nehmen. + let isPaste: Bool = { + if onlyDigits.count >= 3 { return true } + if onlyDigits.count == 2 { + // Wenn beide Ziffern unterschiedlich sind und das erste Zeichen + // dem bisherigen Wert entspricht → Replace-Tipp, kein Paste. + if !previous.isEmpty && onlyDigits.first.map(String.init) == previous { + return false + } + return true + } + return false + }() + + if isPaste { // Paste / Auto-Fill: über Felder ab `index` verteilen let chars = Array(onlyDigits.prefix(6 - index)) for (offset, ch) in chars.enumerated() { @@ -146,7 +164,7 @@ struct LoginView: View { } } let nextFocus = min(index + chars.count, 5) - focusedField = nextFocus + advanceFocus(to: nextFocus) if digits.allSatisfy({ !$0.isEmpty }) && !isLoading { handleSubmit() } @@ -157,20 +175,30 @@ struct LoginView: View { // Backspace digits[index] = "" if index > 0 { - focusedField = index - 1 + advanceFocus(to: index - 1) } return } - digits[index] = String(onlyDigits.prefix(1)) + // Single-digit Eingabe (oder Replace in gefülltes Feld → letzte Ziffer nehmen) + let newDigit = String(onlyDigits.suffix(1)) + digits[index] = newDigit if index < 5 { - focusedField = index + 1 + advanceFocus(to: index + 1) } else if isComplete && !isLoading { // Letztes Feld gefüllt → automatisch absenden handleSubmit() } } + /// Focus-Wechsel muss async passieren, sonst kollidiert er mit dem laufenden + /// TextField-Edit-Cycle und der Focus springt nicht zuverlässig. + private func advanceFocus(to target: Int) { + DispatchQueue.main.async { + focusedField = target + } + } + private func handleSubmit() { guard isComplete, !isLoading else { return } let code = enteredCode diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index fd8ba12..74f12f5 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 67 / versionCode 50) — 2026-06-03\n\n### Fixes + +- DM screen: bottom gap on open tightened — the last message now sits directly above the input bar instead of leaving a visible gap (reduced the keyboard/input-bar clearance padding) + +### Features + +- DM image lightbox: tap a shared photo → it now opens with rounded corners and a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR\n ## v0.3.13 (Build 65 / versionCode 50) — 2026-06-03\n\n### Fixes - DM screen: message text bumped a tick larger (14 → 15px, line height 21 → 22) for better readability diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md new file mode 100644 index 0000000..f0ec396 --- /dev/null +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -0,0 +1,8 @@ +### Fixes + +- DM screen: bottom gap on initial open tightened — the last message now sits directly above the input bar. The keyboard-closed padding was double-counting the input bar's own layout slot, leaving a large empty gap every time you opened a chat +- DM image lightbox: photos now actually show rounded corners — the viewer container is sized to the image's real aspect ratio (via onLoad), so the rounding lands on the visible photo instead of the empty letterbox margins of a fixed square + +### Features + +- DM image lightbox: tap a shared photo → opens with a "Save" button that stores the image to your Photos library (downloads remote images first, asks for photo-add permission once). Localized DE/EN/FR/AR diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 468e4c1..b750ef2 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { supportsTablet: true, bundleIdentifier: MAIN_BUNDLE, - buildNumber: "59", + buildNumber: "67", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // com.apple.developer.applesignin-Entitlement nicht in die .entitlements. @@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 47, + versionCode: 50, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem @@ -84,6 +84,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "expo-localization", "expo-font", "expo-web-browser", + [ + "expo-media-library", + { + photosPermission: "Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.", + savePhotosPermission: "Rebreak speichert Bilder in deine Foto-Mediathek.", + isAccessMediaLocationEnabled: false, + }, + ], [ "expo-build-properties", { diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index 5578c7f..cb9aaff 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -232,6 +232,14 @@ function RootLayoutInner() { animation: 'slide_from_right', }} /> + m.id)); + const extras = local.filter((m) => !serverIds.has(m.id)); + if (extras.length === 0) return server; + return [...server, ...extras].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); +} + export default function DmScreen() { const { t } = useTranslation(); const router = useRouter(); @@ -87,9 +106,17 @@ export default function DmScreen() { const scrollToBottom = useCallback((animated = false) => { flatListRef.current?.scrollToOffset({ offset: 999999, animated }); }, []); - const [messages, setMessages] = useState([]); - const [partner, setPartner] = useState(null); - const partnerRef = useRef(null); + // Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen + // Konversation ist sofort sichtbar (kein Spinner, kein Flash). + const [messages, setMessages] = useState( + () => queryClient.getQueryData(['dm-history', userId])?.messages ?? [], + ); + const [partner, setPartner] = useState( + () => queryClient.getQueryData(['dm-history', userId])?.partner ?? null, + ); + const partnerRef = useRef(partner); + // userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard). + const messagesUserId = useRef(userId); const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( null, ); @@ -102,6 +129,20 @@ export default function DmScreen() { const [inputBarHeight, setInputBarHeight] = useState(60); const [infoSheetOpen, setInfoSheetOpen] = useState(false); const [lightboxUri, setLightboxUri] = useState(null); + // Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um + // den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die + // sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats. + const [lightboxRatio, setLightboxRatio] = useState(null); + const [savingImage, setSavingImage] = useState(false); + + const openLightbox = useCallback((uri: string) => { + setLightboxRatio(null); + setLightboxUri(uri); + }, []); + const closeLightbox = useCallback(() => { + setLightboxUri(null); + setLightboxRatio(null); + }, []); // Voice recording const [isVoiceRecording, setIsVoiceRecording] = useState(false); @@ -112,13 +153,18 @@ export default function DmScreen() { const voiceTimerRef = useRef | null>(null); const voiceStartTime = useRef(0); - // Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse) + // Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft + // wegräumen und sofort auf den Cache der neuen Konversation umschalten: + // vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading). useEffect(() => { - setMessages([]); - setPartner(null); - partnerRef.current = null; + if (messagesUserId.current === userId) return; setReplyTo(null); - }, [userId]); + const cached = queryClient.getQueryData(['dm-history', userId]); + setMessages(cached?.messages ?? []); + setPartner(cached?.partner ?? null); + partnerRef.current = cached?.partner ?? null; + messagesUserId.current = userId; + }, [userId, queryClient]); // Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen useEffect(() => { @@ -147,16 +193,14 @@ export default function DmScreen() { }, [queryClient]), ); - // Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug) - const { isLoading, isFetching } = useQuery({ + // DM-History laden — stale-while-revalidate: gecachte Messages werden sofort + // gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch + // gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass + // ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen. + const { data: historyData, isLoading, isFetching } = useQuery({ queryKey: ['dm-history', userId], queryFn: async () => { - console.log('[dm] fetching history for partner', userId, 'me', myUserId); - try { - const data = await apiFetch(`/api/chat/dm/${userId}`); - console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length); - setPartner(data.partner); - partnerRef.current = data.partner; + const data = await apiFetch(`/api/chat/dm/${userId}`); const msgs: ChatMsg[] = data.messages.map((m: any) => ({ id: m.id, userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), @@ -184,23 +228,27 @@ export default function DmScreen() { reactions: m.reactions ?? [], deleted: m.deleted ?? false, })); - setMessages(msgs); - // Dreistufiges Scroll-to-bottom: rAF + 100ms + 300ms deckt - // Fälle ab wo Bilder nachgeladen werden und Content-Höhe wächst. - requestAnimationFrame(() => scrollToBottom(false)); - setTimeout(() => scrollToBottom(false), 100); - setTimeout(() => scrollToBottom(false), 300); - return data; - } catch (err: any) { - console.error('[dm] history fetch failed:', err?.message ?? err); - throw err; - } + return { partner: data.partner, messages: msgs }; }, enabled: !!userId && !!myUserId, - staleTime: 0, - gcTime: 0, + staleTime: 30_000, + gcTime: 30 * 60_000, }); + // Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit + // Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der + // Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs). + useEffect(() => { + if (!historyData) return; + setPartner(historyData.partner); + partnerRef.current = historyData.partner; + setMessages((prev) => { + const base = messagesUserId.current === userId ? prev : []; + messagesUserId.current = userId; + return mergeMessages(historyData.messages, base); + }); + }, [historyData, userId]); + // Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen useEffect(() => { if (messages.length === 0) return; @@ -234,8 +282,13 @@ export default function DmScreen() { }, ]; }); + // Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als + // gelesen markieren. markDmsAsRead läuft nur im History-GET, also den + // invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge + // hängen, weil dm-conversations die Live-Message als unread zählt. + queryClient.invalidateQueries({ queryKey: ['dm-history', userId] }); }, - [myUserId], + [myUserId, queryClient, userId], ); // Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch. const refetchHistory = useCallback(() => { @@ -243,6 +296,9 @@ export default function DmScreen() { }, [queryClient, userId]); useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); + // Typing-Indicator (ephemerer Broadcast, kein DB-Write) + const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId); + async function pickImage() { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { @@ -326,6 +382,7 @@ export default function DmScreen() { setAttachment(null); setReplyTo(null); setSending(true); + sendStopTyping(); try { let attachmentMeta: { url: string; type: string; name: string } | null = null; @@ -601,12 +658,54 @@ export default function DmScreen() { } } + // Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst + // lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht. + async function saveImage(uri: string) { + if (savingImage) return; + try { + setSavingImage(true); + const perm = await MediaLibrary.requestPermissionsAsync(); + if (!perm.granted) { + Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body')); + return; + } + let localUri = uri; + if (!uri.startsWith('file://')) { + const ext = uri.split('?')[0].split('.').pop() || 'jpg'; + const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`; + const res = await FileSystem.downloadAsync(uri, target); + localUri = res.uri; + } + await MediaLibrary.saveToLibraryAsync(localUri); + Alert.alert(t('chat.image_saved')); + } catch (err: any) { + Alert.alert(t('chat.save_failed'), err?.message ?? ''); + } finally { + setSavingImage(false); + } + } + function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean { if (!a || !b) return false; if (a.userId !== b.userId) return false; return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS; } + // Lightbox-Bildmaße: in die Bildschirmfläche einpassen, Seitenverhältnis wahren. + const lbWin = Dimensions.get('window'); + const lbMaxW = lbWin.width - 24; + const lbMaxH = lbWin.height * 0.78; + let lbW = lbMaxW; + let lbH = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat + if (lightboxRatio) { + lbW = lbMaxW; + lbH = lbMaxW / lightboxRatio; + if (lbH > lbMaxH) { + lbH = lbMaxH; + lbW = lbMaxH * lightboxRatio; + } + } + return ( @@ -632,7 +731,7 @@ export default function DmScreen() { {partner?.nickname ?? '…'} - {userId && } + {userId && } @@ -673,14 +772,20 @@ export default function DmScreen() { onLike={toggleLike} onReact={toggleReaction} onDelete={deleteMessage} - onOpenImage={(url) => setLightboxUri(url)} + onOpenImage={openLightbox} /> )} keyExtractor={(m) => m.id} contentContainerStyle={{ paddingHorizontal: 0, paddingTop: 12, - paddingBottom: inputBarHeight + 12, + // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur, + // der Viewport schrumpft NICHT → Clearance = keyboardHeight. + // Tastatur zu: die Input-Bar (KeyboardStickyView) sitzt in ihrem + // eigenen Layout-Slot UNTER der FlatList, ihre Höhe ist also schon + // abgedeckt — hier nur ein knapper WA-Style-Gap, sonst „schwebt" die + // letzte Nachricht beim Initial-Load zu hoch über der Bar. + paddingBottom: keyboardVisible ? keyboardHeight + 4 : 8, }} showsVerticalScrollIndicator={false} keyboardDismissMode="interactive" @@ -763,7 +868,11 @@ export default function DmScreen() { placeholder={t('chat.placeholder')} placeholderTextColor={colors.textMuted} value={inputText} - onChangeText={setInputText} + onChangeText={(v) => { + setInputText(v); + if (v.trim().length > 0) sendTyping(); + else sendStopTyping(); + }} multiline maxLength={2000} returnKeyType="send" @@ -804,7 +913,7 @@ export default function DmScreen() { onClose={() => setInfoSheetOpen(false)} partner={partner} messages={messages} - onImagePress={(uri) => setLightboxUri(uri)} + onImagePress={openLightbox} onViewProfile={() => { setInfoSheetOpen(false); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); @@ -814,27 +923,58 @@ export default function DmScreen() { /> {/* ── Lightbox ───────────────────────────────────────────────── */} - setLightboxUri(null)}> + setLightboxUri(null)} + onPress={closeLightbox} > {lightboxUri && ( { + const s = e.source; + if (s?.width && s?.height) setLightboxRatio(s.width / s.height); + }} + style={{ width: lbW, height: lbH, borderRadius: 16 }} contentFit="contain" cachePolicy="memory-disk" /> )} setLightboxUri(null)} + onPress={closeLightbox} activeOpacity={0.7} > + {/* Sichern */} + lightboxUri && saveImage(lightboxUri)} + disabled={savingImage} + activeOpacity={0.7} + > + {savingImage ? ( + + ) : ( + + )} + + {t('chat.save')} + + diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx index fc05b53..920d042 100644 --- a/apps/rebreak-native/components/PostCard.tsx +++ b/apps/rebreak-native/components/PostCard.tsx @@ -41,6 +41,25 @@ function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang return raw; } +// @mention-Tokens (z.B. das @Hamed in Lyras Danke-Posts bei Domain-Approval) +// farblich hervorheben, damit klar wird dass eine Person erwähnt ist. Regex: +// @ + Buchstabe + Buchstaben/Ziffern/_ (unicode-aware → matcht auch arabische +// Nicknames). split() mit Capture-Group behält die Tokens im Ergebnis-Array. +const MENTION_RE = /(@[\p{L}][\p{L}\p{N}_]*)/gu; +function renderWithMentions(text: string, accent: string) { + if (!text.includes('@')) return text; + const parts = text.split(MENTION_RE); + return parts.map((part, i) => + i % 2 === 1 ? ( + + {part} + + ) : ( + part + ), + ); +} + type Props = { post: CommunityPost; onCommentPress: (postId: string) => void; @@ -269,7 +288,7 @@ function PostCardImpl({ post, onCommentPress }: Props) { {/* Content — hidden for domain_vote (replaced by poll below) */} {!!displayContent && post.category !== 'domain_vote' && ( - {displayContent} + {renderWithMentions(displayContent, colors.brandOrange)} )} diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx index fca8e50..eed9b65 100644 --- a/apps/rebreak-native/components/chat/ChatBubble.tsx +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -33,21 +33,31 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri const [waveWidth, setWaveWidth] = useState(0); const soundRef = useRef(null); const pollRef = useRef | 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(() => { - const seed = url.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); - // 80 bars, fixed 2dp width via space-between — screen-size-independent thinness - return Array.from({ length: 80 }, (_, i) => { - const a = Math.abs(Math.sin((seed * 0.019 + i) * 2.1)); - const b = Math.abs(Math.sin((seed * 0.037 + i) * 3.7)); - const c2 = Math.abs(Math.sin((seed * 0.073 + i) * 6.3)); - const env = Math.pow(Math.abs(Math.sin((seed * 0.011 + i) * 0.95)), 0.5); - return Math.max(1.5, (a * 0.5 + b * 0.3 + c2 * 0.2) * env * 30); + 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]); @@ -70,15 +80,24 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri 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); @@ -126,7 +145,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri {barHeights.map((h, i) => ( ))} @@ -666,8 +685,8 @@ function makeStyles(colors: ReturnType) { alignItems: 'center', }, content: { - fontSize: 14, - lineHeight: 21, + fontSize: 15, + lineHeight: 22, fontFamily: 'Nunito_400Regular', }, footer: { diff --git a/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx index 7dccc62..e111e33 100644 --- a/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx +++ b/apps/rebreak-native/components/chat/ChatHeaderStatus.tsx @@ -1,12 +1,17 @@ -import { Text } from 'react-native'; +import { useEffect, useRef } from 'react'; +import { Text, View, Animated, Easing } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useOnlineUsers } from '../../hooks/useOnlineUsers'; import { useLastSeenBatch } from '../../hooks/useLastSeenBatch'; type Props = { userId: string; + /** Partner tippt gerade → überschreibt Online/Last-Seen mit „schreibt …". */ + typing?: boolean; }; +const STATUS_COLOR = '#a3a3a3'; + function formatLastSeen(ts: string, t: (key: string, opts?: Record) => string): string { const diff = Date.now() - new Date(ts).getTime(); if (diff < 60_000) return t('presence.just_now'); @@ -15,17 +20,63 @@ function formatLastSeen(ts: string, t: (key: string, opts?: Record { + const loops = dots.map((d, i) => + Animated.loop( + Animated.sequence([ + Animated.delay(i * 160), + Animated.timing(d, { toValue: 1, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.timing(d, { toValue: 0.3, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }), + Animated.delay((dots.length - 1 - i) * 160), + ]), + ), + ); + loops.forEach((l) => l.start()); + return () => loops.forEach((l) => l.stop()); + }, [dots]); + + return ( + + {dots.map((d, i) => ( + + ))} + + ); +} + +export function ChatHeaderStatus({ userId, typing }: Props) { const { t } = useTranslation(); - const { isOnline } = useOnlineUsers(); - const lastSeenMap = useLastSeenBatch(isOnline(userId) ? [] : [userId]); - const online = isOnline(userId); + // DM-Header zeigt den ECHTEN Presence-Status des Partners (wie WhatsApp) — + // NICHT die following-gated `isOnline`-Variante aus dem Feed/Profil. Wer dir + // schreibt, sieht ohnehin via Typing-Indicator dass du da bist. `onlineUserIds` + // ist der rohe Presence-Set → updatet live über den Presence-Sync-Channel. + const { onlineUserIds } = useOnlineUsers(); + const online = onlineUserIds.has(userId); + const lastSeenMap = useLastSeenBatch(online ? [] : [userId]); + + if (typing) { + return ( + + + {t('presence.typing')} + + + + ); + } if (online) { // User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht // als Farb-Signal). Neutraler `textMuted`-Grau-Ton. return ( - + {t('presence.online')} ); @@ -35,7 +86,7 @@ export function ChatHeaderStatus({ userId }: Props) { if (!lastSeen) return null; return ( - + {formatLastSeen(lastSeen, t)} ); diff --git a/apps/rebreak-native/hooks/useCustomDomains.ts b/apps/rebreak-native/hooks/useCustomDomains.ts index c8aa1de..c51798a 100644 --- a/apps/rebreak-native/hooks/useCustomDomains.ts +++ b/apps/rebreak-native/hooks/useCustomDomains.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useState } from 'react'; -import { apiFetch } from '../lib/api'; -import { resolveVipCountry } from './useWebContentDomains'; -import { useBlockerStatsStore } from '../stores/blockerStats'; +import { useCallback, useEffect, useState } from "react"; +import { apiFetch } from "../lib/api"; +import { resolveVipCountry } from "./useWebContentDomains"; +import { useBlockerStatsStore } from "../stores/blockerStats"; -export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected'; +export type DomainStatus = "active" | "submitted" | "approved" | "rejected"; -export type EntryKind = 'web' | 'mail_domain' | 'mail_display_name'; +export type EntryKind = "web" | "mail_domain" | "mail_display_name"; export type CustomDomain = { id: string; @@ -14,12 +14,17 @@ export type CustomDomain = { status: DomainStatus; addedAt?: string; postId?: string | null; - submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null; + submission?: { + id: string; + yesVotes: number; + noVotes: number; + status: string; + } | null; vipDeferUntil?: string | null; vipEvictAt?: string | null; }; -export type Plan = 'free' | 'pro' | 'legend'; +export type Plan = "free" | "pro" | "legend"; /** * Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des @@ -43,19 +48,21 @@ export type AddDomainResult = { export type Tier = { plan: Plan; - domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam) - refillEnabled: boolean; // pro/legend=true - globalBlocklist: boolean; // pro/legend=true - canSubmit: boolean; // pro/legend=true - usedSlots: number; // active+submitted (NICHT approved/rejected) + domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam) + refillEnabled: boolean; // pro/legend=true + globalBlocklist: boolean; // pro/legend=true + canSubmit: boolean; // pro/legend=true + usedSlots: number; // active+submitted (NICHT approved/rejected) atLimit: boolean; }; function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { // Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen. - const limit = plan === 'legend' ? 20 : 10; - const refill = plan !== 'free'; - const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; + const limit = plan === "legend" ? 20 : 10; + const refill = plan !== "free"; + const usedSlots = domains.filter( + (d) => d.status === "active" || d.status === "submitted", + ).length; return { plan, domainLimit: limit, @@ -79,12 +86,15 @@ export type UseCustomDomainsReturn = { refresh: () => Promise; addDomain: ( pattern: string, - kind?: 'web' | 'mail', + kind?: "web" | "mail", opts?: { addToVip?: boolean }, ) => Promise; submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; - submitVipSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>; + submitVipSwap: ( + newDomainId: string, + evictedDomainId: string, + ) => Promise<{ ok: boolean; error?: string }>; /** Live-Validate (regex) ob string gültiger Domain-Name ist. */ isValidDomain: (s: string) => boolean; /** Normalize: lowercase, http(s)://, /path stripping, www. weg. */ @@ -95,11 +105,11 @@ const DOMAIN_REGEX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i; export function normalizeDomain(input: string): string { let s = input.trim().toLowerCase(); - if (s.startsWith('https://')) s = s.slice(8); - else if (s.startsWith('http://')) s = s.slice(7); - const slash = s.indexOf('/'); + if (s.startsWith("https://")) s = s.slice(8); + else if (s.startsWith("http://")) s = s.slice(7); + const slash = s.indexOf("/"); if (slash >= 0) s = s.slice(0, slash); - if (s.startsWith('www.')) s = s.slice(4); + if (s.startsWith("www.")) s = s.slice(4); return s; } @@ -118,21 +128,67 @@ export function isValidDomain(input: string): boolean { * — bei Änderungen beide synchron halten. */ const PUBLIC_EMAIL_DOMAINS = new Set([ - 'gmail.com', 'googlemail.com', - 'icloud.com', 'me.com', 'mac.com', - 'outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'hotmail.co.uk', - 'hotmail.fr', 'live.com', 'live.de', 'msn.com', - 'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'yahoo.fr', 'ymail.com', 'rocketmail.com', - 'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com', 'web.de', - 'aol.com', 'aim.com', - 'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', - 'tuta.io', 'posteo.de', 'posteo.net', 'mailbox.org', 'hey.com', - 't-online.de', 'freenet.de', 'arcor.de', - 'mail.com', 'mail.de', 'email.de', 'zoho.com', 'fastmail.com', 'fastmail.fm', - 'hushmail.com', - 'yandex.com', 'yandex.ru', 'mail.ru', - 'laposte.net', 'orange.fr', 'free.fr', 'sfr.fr', 'wanadoo.fr', - 'qq.com', '163.com', '126.com', 'naver.com', 'daum.net', + "gmail.com", + "googlemail.com", + "icloud.com", + "me.com", + "mac.com", + "outlook.com", + "outlook.de", + "hotmail.com", + "hotmail.de", + "hotmail.co.uk", + "hotmail.fr", + "live.com", + "live.de", + "msn.com", + "yahoo.com", + "yahoo.de", + "yahoo.co.uk", + "yahoo.fr", + "ymail.com", + "rocketmail.com", + "gmx.de", + "gmx.net", + "gmx.at", + "gmx.ch", + "gmx.com", + "web.de", + "aol.com", + "aim.com", + "proton.me", + "protonmail.com", + "pm.me", + "tutanota.com", + "tutanota.de", + "tuta.io", + "posteo.de", + "posteo.net", + "mailbox.org", + "hey.com", + "t-online.de", + "freenet.de", + "arcor.de", + "mail.com", + "mail.de", + "email.de", + "zoho.com", + "fastmail.com", + "fastmail.fm", + "hushmail.com", + "yandex.com", + "yandex.ru", + "mail.ru", + "laposte.net", + "orange.fr", + "free.fr", + "sfr.fr", + "wanadoo.fr", + "qq.com", + "163.com", + "126.com", + "naver.com", + "daum.net", ]); export function isPublicEmailDomain(domain: string): boolean { @@ -163,8 +219,13 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { // trifft bevor das Deploy landet. const res = await apiFetch< | CustomDomain[] - | { items?: CustomDomain[]; domains?: CustomDomain[]; count?: number; limit?: number } - >('/api/custom-domains'); + | { + items?: CustomDomain[]; + domains?: CustomDomain[]; + count?: number; + limit?: number; + } + >("/api/custom-domains"); let arr: CustomDomain[] = []; let count: number | null = null; let limit: number | null = null; @@ -172,16 +233,18 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { arr = res; } else if (res) { arr = (res as any).items ?? (res as any).domains ?? []; - count = typeof (res as any).count === 'number' ? (res as any).count : null; - limit = typeof (res as any).limit === 'number' ? (res as any).limit : null; + count = + typeof (res as any).count === "number" ? (res as any).count : null; + limit = + typeof (res as any).limit === "number" ? (res as any).limit : null; } setDomains(arr); setApiCount(count); setApiLimit(limit); setError(null); } catch (e: any) { - console.error('[useCustomDomains] fetch failed:', e?.message ?? e); - setError(e?.message ?? 'unknown'); + console.error("[useCustomDomains] fetch failed:", e?.message ?? e); + setError(e?.message ?? "unknown"); } finally { setLoading(false); } @@ -194,45 +257,56 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const addDomain = useCallback( async ( input: string, - kind?: 'web' | 'mail', + kind?: "web" | "mail", opts?: { addToVip?: boolean }, ): Promise => { - const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web'); - if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; - if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; + const resolvedKind: "web" | "mail" = + kind ?? (input.includes("@") ? "mail" : "web"); + if (resolvedKind === "web" && !isValidDomain(input)) + return { ok: false, error: "invalid_domain" }; + if (resolvedKind === "mail" && !input.trim()) + return { ok: false, error: "invalid_pattern" }; // Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of // Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit // geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED. // Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot. - if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) { - return { ok: false, error: 'limit_reached' }; + if ( + !opts?.addToVip && + apiCount != null && + apiLimit != null && + apiCount >= apiLimit + ) { + return { ok: false, error: "limit_reached" }; } - const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim(); + const pattern = + resolvedKind === "web" ? normalizeDomain(input) : input.trim(); // Public-/Freemail-Domain (icloud.com, gmail.com …) hart ablehnen — web UND // mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren. const domainToCheck = - resolvedKind === 'mail' && pattern.includes('@') - ? pattern.slice(pattern.lastIndexOf('@') + 1) + resolvedKind === "mail" && pattern.includes("@") + ? pattern.slice(pattern.lastIndexOf("@") + 1) : pattern; - if (isPublicEmailDomain(domainToCheck)) return { ok: false, error: 'public_domain' }; + if (isPublicEmailDomain(domainToCheck)) + return { ok: false, error: "public_domain" }; const body: Record = { pattern }; if (kind !== undefined) body.kind = kind; // Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes. - if (resolvedKind === 'web') body.country = resolveVipCountry(); + if (resolvedKind === "web") body.country = resolveVipCountry(); if (opts?.addToVip) body.addToVip = true; try { - const res = await apiFetch('/api/custom-domains', { - method: 'POST', + const res = await apiFetch("/api/custom-domains", { + method: "POST", body, }); if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true }; if (res?.alreadyProtected) return { ok: false, alreadyProtected: true }; if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true }; await fetchDomains(); - if (res?.vipFull) return { ok: true, vipFull: true, newDomainId: res.id }; + if (res?.vipFull) + return { ok: true, vipFull: true, newDomainId: res.id }; return { ok: true, addedToVip: res?.addedToVip === true }; } catch (e: any) { - return { ok: false, error: e?.message ?? 'add_failed' }; + return { ok: false, error: e?.message ?? "add_failed" }; } }, [apiCount, apiLimit, fetchDomains], @@ -241,16 +315,20 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const submitDomain = useCallback( async (id: string) => { const tier = deriveTier(plan, domains); - if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' }; + if (!tier.canSubmit) + return { ok: false, error: "plan_does_not_support_submit" }; try { - await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} }); + await apiFetch(`/api/custom-domains/${id}/submit`, { + method: "POST", + body: {}, + }); // Optimistisches lokales Update: Half-Donut im ProtectionDetailsSheet // soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten. useBlockerStatsStore.getState().bumpMyInReview(1); await fetchDomains(); return { ok: true }; } catch (e: any) { - return { ok: false, error: e?.message ?? 'submit_failed' }; + return { ok: false, error: e?.message ?? "submit_failed" }; } }, [plan, domains, fetchDomains], @@ -259,11 +337,11 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const removeDomain = useCallback( async (id: string) => { try { - await apiFetch(`/api/custom-domains/${id}`, { method: 'DELETE' }); + await apiFetch(`/api/custom-domains/${id}`, { method: "DELETE" }); await fetchDomains(); return { ok: true }; } catch (e: any) { - return { ok: false, error: e?.message ?? 'remove_failed' }; + return { ok: false, error: e?.message ?? "remove_failed" }; } }, [fetchDomains], @@ -272,14 +350,14 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { const submitVipSwap = useCallback( async (newDomainId: string, evictedDomainId: string) => { try { - await apiFetch('/api/custom-domains/vip-swap', { - method: 'POST', + await apiFetch("/api/custom-domains/vip-swap", { + method: "POST", body: { newDomainId, evictedDomainId }, }); await fetchDomains(); return { ok: true }; } catch (e: any) { - return { ok: false, error: e?.message ?? 'vip_swap_failed' }; + return { ok: false, error: e?.message ?? "vip_swap_failed" }; } }, [fetchDomains], @@ -291,8 +369,9 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn { // Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert. const count: number = apiCount ?? - domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; - const limit: number = apiLimit ?? (plan === 'legend' ? 20 : 10); + domains.filter((d) => d.status === "active" || d.status === "submitted") + .length; + const limit: number = apiLimit ?? (plan === "legend" ? 20 : 10); return { domains, diff --git a/apps/rebreak-native/hooks/useDmTyping.ts b/apps/rebreak-native/hooks/useDmTyping.ts new file mode 100644 index 0000000..64e603f --- /dev/null +++ b/apps/rebreak-native/hooks/useDmTyping.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { supabase } from '../lib/supabase'; +import type { RealtimeChannel } from '@supabase/supabase-js'; + +/** + * Typing-Indicator für eine DM-Konversation via Supabase-Broadcast (ephemer, + * KEIN DB-Write — Tipp-Status muss nicht persistiert werden). + * + * Beide Peers joinen denselben deterministischen Channel (sortiertes ID-Paar), + * damit `send()` von A bei B ankommt. `self:false` filtert die eigenen Events. + * + * - `sendTyping()` → throttled-Broadcast „ich tippe" (max 1×/1.5s) + * - `sendStopTyping()` → sofortiger „Stop" (beim Senden / Leeren des Inputs) + * - `partnerTyping` → true solange Partner-Events reinkommen (Auto-Clear 4s) + */ +export function useDmTyping(myUserId: string | undefined, partnerId: string | undefined) { + const [partnerTyping, setPartnerTyping] = useState(false); + const channelRef = useRef(null); + const clearTimer = useRef | null>(null); + const lastSent = useRef(0); + + useEffect(() => { + if (!myUserId || !partnerId) return; + const pair = [myUserId, partnerId].sort().join('_'); + const channel = supabase.channel(`dm-typing:${pair}`, { + config: { broadcast: { self: false } }, + }); + channel + .on('broadcast', { event: 'typing' }, (msg: any) => { + if (msg?.payload?.userId !== partnerId) return; + setPartnerTyping(true); + if (clearTimer.current) clearTimeout(clearTimer.current); + clearTimer.current = setTimeout(() => setPartnerTyping(false), 4000); + }) + .on('broadcast', { event: 'stop_typing' }, (msg: any) => { + if (msg?.payload?.userId !== partnerId) return; + if (clearTimer.current) clearTimeout(clearTimer.current); + setPartnerTyping(false); + }) + .subscribe(); + channelRef.current = channel; + + return () => { + if (clearTimer.current) clearTimeout(clearTimer.current); + supabase.removeChannel(channel); + channelRef.current = null; + setPartnerTyping(false); + }; + }, [myUserId, partnerId]); + + const sendTyping = useCallback(() => { + const now = Date.now(); + if (now - lastSent.current < 1500) return; // Throttle + lastSent.current = now; + channelRef.current?.send({ + type: 'broadcast', + event: 'typing', + payload: { userId: myUserId }, + }); + }, [myUserId]); + + const sendStopTyping = useCallback(() => { + lastSent.current = 0; + channelRef.current?.send({ + type: 'broadcast', + event: 'stop_typing', + payload: { userId: myUserId }, + }); + }, [myUserId]); + + return { partnerTyping, sendTyping, sendStopTyping }; +} diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 8218367..3bb8a3d 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -1033,6 +1033,9 @@ "image_attachment": "صورة", "file_attachment": "ملف", "upload_failed": "فشل الرفع", + "save": "حفظ", + "image_saved": "تم حفظ الصورة في الصور", + "save_failed": "تعذّر حفظ الصورة", "member_count": "%{n} أعضاء", "member_count_online": "%{n} أعضاء · %{online} متصل", "pending_request": "طلبات الانضمام", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index d3557d8..cc7c08e 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1104,6 +1104,9 @@ "image_attachment": "Bild", "file_attachment": "Datei", "upload_failed": "Upload fehlgeschlagen", + "save": "Sichern", + "image_saved": "Bild in Fotos gesichert", + "save_failed": "Bild konnte nicht gesichert werden", "member_count": "%{n} Mitglieder", "member_count_online": "%{n} Mitglieder · %{online} online", "pending_request": "Beitrittsanfragen", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 0a23760..48e259c 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1102,6 +1102,9 @@ "image_attachment": "Image", "file_attachment": "File", "upload_failed": "Upload failed", + "save": "Save", + "image_saved": "Image saved to Photos", + "save_failed": "Could not save image", "member_count": "%{n} members", "member_count_online": "%{n} members · %{online} online", "pending_request": "Join requests", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index cd14771..7c885e9 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -1022,6 +1022,9 @@ "image_attachment": "Image", "file_attachment": "Fichier", "upload_failed": "Échec du téléversement", + "save": "Enregistrer", + "image_saved": "Image enregistrée dans Photos", + "save_failed": "Impossible d'enregistrer l'image", "member_count": "%{n} membres", "member_count_online": "%{n} membres · %{online} en ligne", "pending_request": "Demandes d'adhésion", diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist index 555756b..fb8d354 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 59 + 67 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist index c283bf2..3db75c4 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 59 + 67 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index c1d0273..44ba003 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 59 + 67 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index 8079747..bccff36 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -43,6 +43,7 @@ "expo-linking": "~8.0.12", "expo-local-authentication": "~17.0.8", "expo-localization": "~17.0.8", + "expo-media-library": "~18.2.1", "expo-modules-core": "^3.0.30", "expo-notifications": "~0.32.17", "expo-router": "~6.0.23", diff --git a/apps/rebreak-native/stores/blockerStats.ts b/apps/rebreak-native/stores/blockerStats.ts index 115d882..17a9f93 100644 --- a/apps/rebreak-native/stores/blockerStats.ts +++ b/apps/rebreak-native/stores/blockerStats.ts @@ -1,5 +1,5 @@ -import { create } from 'zustand'; -import { apiFetch } from '../lib/api'; +import { create } from "zustand"; +import { apiFetch } from "../lib/api"; export type BlockerStats = { current: number; @@ -61,7 +61,7 @@ type BlockerStatsState = { let inFlight: Promise | null = null; function asNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : 0; + return typeof value === "number" && Number.isFinite(value) ? value : 0; } function normalizeStats(raw: RawStatsResponse): BlockerStats { @@ -76,12 +76,11 @@ function normalizeStats(raw: RawStatsResponse): BlockerStats { asNumber(raw.mySubmissions?.pending); const approvedMine = - asNumber(raw.mySubmissions?.approved) + - asNumber(raw.mySubmissions?.active); + asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.active); const history = Array.isArray(raw.history) ? raw.history.map((h) => ({ - label: typeof h?.label === 'string' ? h.label : '', + label: typeof h?.label === "string" ? h.label : "", count: asNumber(h?.count), })) : []; @@ -118,14 +117,14 @@ export const useBlockerStatsStore = create((set, get) => ({ inFlight = (async () => { set((s) => ({ ...s, loading: true, error: null })); try { - const raw = await apiFetch('/api/blocklist/stats'); + const raw = await apiFetch("/api/blocklist/stats"); const stats = normalizeStats(raw ?? {}); set({ stats, loading: false, error: null, fetchedAt: Date.now() }); } catch (e: any) { set((s) => ({ ...s, loading: false, - error: e?.message ?? 'stats_fetch_failed', + error: e?.message ?? "stats_fetch_failed", })); } finally { inFlight = null; diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index 0a29517..a8a60b4 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -29,9 +29,15 @@ Building Release AAB (gradlew bundleRelease)|307 Validating IPA (App-Store Connect)|83 Uploading zu App-Store Connect (TestFlight)|103 Building Release AAB (gradlew bundleRelease)|370 -Exporting App-Store IPA|25 Validating IPA (App-Store Connect)|115 Uploading zu App-Store Connect (TestFlight)|147 Building Release AAB (gradlew bundleRelease)|320 -Building xcarchive|223 -Exporting Ad-Hoc IPA|20 +Validating IPA (App-Store Connect)|105 +Uploading zu App-Store Connect (TestFlight)|117 +Building Release AAB (gradlew bundleRelease)|398 +Exporting App-Store IPA|24 +Validating IPA (App-Store Connect)|91 +Uploading zu App-Store Connect (TestFlight)|110 +Building Release AAB (gradlew bundleRelease)|326 +Building xcarchive|198 +Exporting Ad-Hoc IPA|19 diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index fdb7f13..32e3d52 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -10,10 +10,6 @@ export default defineNitroConfig({ // Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt. publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }], - // Server-Assets: zur Build-Time eingebundelte Files (mobileconfig-Template etc.). - // Lesbar via useStorage('assets:server').getItem('mdm/'). - serverAssets: [{ baseName: "mdm", dir: "../ops/mdm" }], - // Supabase als external dep — nicht bundlen externals: { inline: [/^(?!@supabase\/supabase-js)/], diff --git a/backend/server/api/magic/profile.mobileconfig.get.ts b/backend/server/api/magic/profile.mobileconfig.get.ts index b790def..339b639 100644 --- a/backend/server/api/magic/profile.mobileconfig.get.ts +++ b/backend/server/api/magic/profile.mobileconfig.get.ts @@ -1,12 +1,14 @@ import { randomUUID } from "crypto"; import { findMagicDeviceByToken } from "../../db/devices"; +import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template"; /** * GET /api/magic/profile.mobileconfig?token= * * Generiert personalisiertes DNS-Configuration-Profile für macOS. - * Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (via Nitro serverAssets - * unter baseName "mdm" eingebundelt — siehe nitro.config.ts). + * Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS + * constant via backend/server/utils/magic-profile-template.ts — überlebt + * jeden Build/Deploy ohne FS- oder serverAssets-Magic). * * Ersetzt: * - ServerURL: /dns-query → /dns-query/{token} @@ -36,20 +38,7 @@ export default defineEventHandler(async (event) => { } // Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig). - const storage = useStorage("assets:server"); - const template = (await storage.getItem( - "mdm/rebreak-mac-dns-filter.mobileconfig", - )) as string | null; - - if (!template) { - console.error( - "[Magic] Profile template missing in serverAssets (mdm/rebreak-mac-dns-filter.mobileconfig)", - ); - throw createError({ - statusCode: 500, - message: "Profile template not found", - }); - } + const template = MAGIC_PROFILE_TEMPLATE; // ServerURL ersetzen: /dns-query → /dns-query/{token} const personalizedProfile = template diff --git a/backend/server/utils/magic-profile-template.ts b/backend/server/utils/magic-profile-template.ts new file mode 100644 index 0000000..7358495 --- /dev/null +++ b/backend/server/utils/magic-profile-template.ts @@ -0,0 +1,58 @@ +/** + * Inlined Mac DNS-Filter mobileconfig template. + * + * Single source of truth lives at ops/mdm/rebreak-mac-dns-filter.mobileconfig. + * Bundled here as a TS string so it survives the Nitro build without + * relying on serverAssets/process.cwd() path resolution (both proved + * brittle on the staging deploy layout). If you change the canonical + * file under ops/mdm, copy the contents here verbatim. + */ +export const MAGIC_PROFILE_TEMPLATE = ` + + + + PayloadContent + + + PayloadDisplayName + ReBreak DNS-Filter + PayloadDescription + Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert. + PayloadIdentifier + org.rebreak.protection.dns.filter + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + 7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0 + PayloadVersion + 1 + DNSSettings + + DNSProtocol + HTTPS + ServerURL + https://dns.rebreak.org/dns-query + + + + PayloadDisplayName + ReBreak Schutz + PayloadDescription + Aktiviert den ReBreak-DNS-Filter auf diesem Mac. Glücksspiel-Domains werden auf System-Ebene blockiert — gilt für alle Browser, alle Apps. Kann via Systemeinstellungen → Allgemein → Geräteverwaltung entfernt werden (Admin-Passwort erforderlich). + PayloadIdentifier + org.rebreak.protection.profile + PayloadOrganization + ReBreak + PayloadType + Configuration + PayloadUUID + 8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901 + PayloadVersion + 1 + PayloadScope + System + PayloadRemovalDisallowed + + + +`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52dc8b1..3ddaaf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,7 +42,7 @@ importers: version: 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/nuxt': specifier: ^14.2.1 - version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) nuxt: specifier: 4.1.3 version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4) @@ -61,7 +61,7 @@ importers: version: 1.2.3 '@nuxt/devtools': specifier: latest - version: 4.0.0-alpha.6(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + version: 4.0.0-alpha.7(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -73,7 +73,7 @@ importers: version: 1.2.3 '@nuxt/fonts': specifier: ^0.11.4 - version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) + version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3) '@nuxt/icon': specifier: ^1.10.0 version: 1.15.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) @@ -91,7 +91,7 @@ importers: version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3)) '@vueuse/nuxt': specifier: ^14.2.1 - version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -113,7 +113,7 @@ importers: devDependencies: '@nuxt/devtools': specifier: latest - version: 4.0.0-alpha.6(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) + version: 4.0.0-alpha.7(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -213,6 +213,9 @@ importers: expo-localization: specifier: ~17.0.8 version: 17.0.8(expo@54.0.34)(react@19.1.0) + expo-media-library: + specifier: ~18.2.1 + version: 18.2.1(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)) expo-modules-core: specifier: ^3.0.30 version: 3.0.30(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) @@ -2051,8 +2054,8 @@ packages: peerDependencies: vite: '>=6.0' - '@nuxt/devtools-kit@4.0.0-alpha.6': - resolution: {integrity: sha512-bmsjBu6SymaHeD6Bt5DBvUBuZ9MtYRflGL0RHEdbTt7cILVK4te1i/kwCshXAeckxla6tBsadl6rqyjmRFc69Q==} + '@nuxt/devtools-kit@4.0.0-alpha.7': + resolution: {integrity: sha512-Tgh+tSejh1GnZjdjgWyc4qCxskeX08XuSQBYMn/4SIV5AubeqYeAOMBD2qSmHOXjMCUpgyzpEhODcP3sgdgGRA==} peerDependencies: vite: '>=6.0' @@ -2066,8 +2069,8 @@ packages: peerDependencies: vite: '>=6.0' - '@nuxt/devtools@4.0.0-alpha.6': - resolution: {integrity: sha512-5u6oB0UeBwCG6lIxLGcxqVwqTcmXiN4FiLCDJAQqi7rwJRkwTB7kdml9Nd6sraX2z5vuS3bsRyAav+8t6S3ryw==} + '@nuxt/devtools@4.0.0-alpha.7': + resolution: {integrity: sha512-ZWPhutVNQwBx1AmjRbaVEvDEl6JT6bIF9s6v/lorMOhNNV99TdfOcv5o8kytdFNhkzzIsAyIFB09bK3gj0y61Q==} peerDependencies: vite: '>=6.0' @@ -5622,6 +5625,12 @@ packages: peerDependencies: expo: '*' + expo-media-library@18.2.1: + resolution: {integrity: sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==} + peerDependencies: + expo: '*' + react-native: '*' + expo-modules-autolinking@3.0.25: resolution: {integrity: sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==} hasBin: true @@ -11445,7 +11454,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/devtools-kit@4.0.0-alpha.6(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': + '@nuxt/devtools-kit@4.0.0-alpha.7(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': dependencies: '@nuxt/kit': 4.4.6(magicast@0.5.3) tinyexec: 1.2.3 @@ -11505,9 +11514,9 @@ snapshots: - utf-8-validate - vue - '@nuxt/devtools@4.0.0-alpha.6(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': + '@nuxt/devtools@4.0.0-alpha.7(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': dependencies: - '@nuxt/devtools-kit': 4.0.0-alpha.6(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) + '@nuxt/devtools-kit': 4.0.0-alpha.7(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) '@nuxt/kit': 4.4.6(magicast@0.5.3) '@vitejs/devtools': 0.3.1(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) '@vitejs/devtools-kit': 0.3.1(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) @@ -11566,7 +11575,7 @@ snapshots: - utf-8-validate - vue - '@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))': + '@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)': dependencies: '@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)) '@nuxt/kit': 3.21.4(magicast@0.5.3) @@ -14170,7 +14179,7 @@ snapshots: transitivePeerDependencies: - magicast - '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': + '@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))': dependencies: '@nuxt/kit': 4.4.4(magicast@0.5.3) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) @@ -15724,6 +15733,11 @@ snapshots: transitivePeerDependencies: - supports-color + expo-media-library@18.2.1(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)): + dependencies: + expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + expo-modules-autolinking@3.0.25: dependencies: '@expo/spawn-async': 1.7.2