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.
This commit is contained in:
chahinebrini 2026-06-03 09:57:27 +02:00
parent d212452a5d
commit 8670b45351
25 changed files with 696 additions and 182 deletions

View File

@ -133,10 +133,28 @@ struct LoginView: View {
// MARK: - Logic
private func handleDigitInput(_ raw: String, at index: Int) {
// Erlaubt: 09. Mehrere Zeichen (Paste) über alle Felder verteilen.
// Erlaubt: 09. 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

View File

@ -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

View File

@ -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

View File

@ -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",
{

View File

@ -232,6 +232,14 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="magic"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="onboarding/index"
options={{

View File

@ -24,6 +24,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker';
import * as MediaLibrary from 'expo-media-library';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
@ -32,6 +33,7 @@ import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/Voice
import { DmChatBackground } from '../components/chat/DmChatBackground';
import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme';
import { useAuthStore } from '../stores/auth';
@ -65,6 +67,23 @@ type DmHistoryResponse = {
const GROUP_GAP_MS = 5 * 60 * 1000;
type DmData = {
partner: DmHistoryResponse['partner'];
messages: ChatMsg[];
};
// Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras
// (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch
// nicht kannte) bleiben erhalten und werden nach createdAt einsortiert.
function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] {
const serverIds = new Set(server.map((m) => 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<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
// Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
// Konversation ist sofort sichtbar (kein Spinner, kein Flash).
const [messages, setMessages] = useState<ChatMsg[]>(
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.messages ?? [],
);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.partner ?? null,
);
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(partner);
// userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard).
const messagesUserId = useRef<string | undefined>(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<string | null>(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<number | null>(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<ReturnType<typeof setInterval> | 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<DmData>(['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<DmData>({
queryKey: ['dm-history', userId],
queryFn: async () => {
console.log('[dm] fetching history for partner', userId, 'me', myUserId);
try {
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
setPartner(data.partner);
partnerRef.current = data.partner;
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 (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={[styles.header, { backgroundColor: colors.bg }]}>
@ -632,7 +731,7 @@ export default function DmScreen() {
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
{userId && <ChatHeaderStatus userId={userId} />}
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
</View>
</TouchableOpacity>
@ -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 ───────────────────────────────────────────────── */}
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={() => setLightboxUri(null)}>
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
<TouchableOpacity
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
activeOpacity={1}
onPress={() => setLightboxUri(null)}
onPress={closeLightbox}
>
{lightboxUri && (
<Image
source={{ uri: lightboxUri }}
style={{ width: Dimensions.get('window').width, height: Dimensions.get('window').width }}
onLoad={(e) => {
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"
/>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
onPress={() => setLightboxUri(null)}
onPress={closeLightbox}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
{/* Sichern */}
<TouchableOpacity
style={{
position: 'absolute',
bottom: 54,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.16)',
}}
onPress={() => lightboxUri && saveImage(lightboxUri)}
disabled={savingImage}
activeOpacity={0.7}
>
{savingImage ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="download-outline" size={20} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
{t('chat.save')}
</Text>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
</SafeAreaView>

View File

@ -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 ? (
<Text key={i} style={{ color: accent, fontFamily: 'Nunito_700Bold' }}>
{part}
</Text>
) : (
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' && (
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
{displayContent}
{renderWithMentions(displayContent, colors.brandOrange)}
</Text>
)}

View File

@ -33,21 +33,31 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
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(() => {
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) => (
<View
key={i}
style={{ width: 2, height: h, borderRadius: 1, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
/>
))}
</View>
@ -666,8 +685,8 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
alignItems: 'center',
},
content: {
fontSize: 14,
lineHeight: 21,
fontSize: 15,
lineHeight: 22,
fontFamily: 'Nunito_400Regular',
},
footer: {

View File

@ -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, unknown>) => 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<string, unkno
return t('presence.days_ago', { days: Math.floor(diff / 86_400_000) });
}
export function ChatHeaderStatus({ userId }: Props) {
/** Drei pulsierende Punkte (WA/Insta-Style) neben dem „schreibt"-Text. */
function TypingDots() {
const dots = useRef([new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)]).current;
useEffect(() => {
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 (
<View style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 4, gap: 2 }}>
{dots.map((d, i) => (
<Animated.View
key={i}
style={{ width: 3, height: 3, borderRadius: 1.5, backgroundColor: STATUS_COLOR, opacity: d }}
/>
))}
</View>
);
}
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 (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{t('presence.typing')}
</Text>
<TypingDots />
</View>
);
}
if (online) {
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton.
return (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{t('presence.online')}
</Text>
);
@ -35,7 +86,7 @@ export function ChatHeaderStatus({ userId }: Props) {
if (!lastSeen) return null;
return (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{formatLastSeen(lastSeen, t)}
</Text>
);

View File

@ -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
@ -53,9 +58,11 @@ export type Tier = {
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<void>;
addDomain: (
pattern: string,
kind?: 'web' | 'mail',
kind?: "web" | "mail",
opts?: { addToVip?: boolean },
) => Promise<AddDomainResult>;
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<string>([
'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<AddDomainResult> => {
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<string, string | boolean> = { 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<any>('/api/custom-domains', {
method: 'POST',
const res = await apiFetch<any>("/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,

View File

@ -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<RealtimeChannel | null>(null);
const clearTimer = useRef<ReturnType<typeof setTimeout> | 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 };
}

View File

@ -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": "طلبات الانضمام",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>59</string>
<string>67</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>59</string>
<string>67</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<key>CFBundleVersion</key>
<string>59</string>
<string>67</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>

View File

@ -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",

View File

@ -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<void> | 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<BlockerStatsState>((set, get) => ({
inFlight = (async () => {
set((s) => ({ ...s, loading: true, error: null }));
try {
const raw = await apiFetch<RawStatsResponse>('/api/blocklist/stats');
const raw = await apiFetch<RawStatsResponse>("/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;

View File

@ -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

View File

@ -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/<file>').
serverAssets: [{ baseName: "mdm", dir: "../ops/mdm" }],
// Supabase als external dep — nicht bundlen
externals: {
inline: [/^(?!@supabase\/supabase-js)/],

View File

@ -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=<dnsToken>
*
* 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

View File

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>ReBreak DNS-Filter</string>
<key>PayloadDescription</key>
<string>Leitet DNS-Anfragen über dns.rebreak.org. Glücksspiel-Domains werden blockiert.</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.dns.filter</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>7D2E8B1A-C3D4-4E76-8B23-A4B5C6D7E8F0</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>https://dns.rebreak.org/dns-query</string>
</dict>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>ReBreak Schutz</string>
<key>PayloadDescription</key>
<string>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).</string>
<key>PayloadIdentifier</key>
<string>org.rebreak.protection.profile</string>
<key>PayloadOrganization</key>
<string>ReBreak</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>8C3F9A2B-D4E5-4F87-9A12-B5C6D7E8F901</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadRemovalDisallowed</key>
<true/>
</dict>
</plist>
`;

42
pnpm-lock.yaml generated
View File

@ -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