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