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 // MARK: - Logic
private func handleDigitInput(_ raw: String, at index: Int) { 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 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 // Paste / Auto-Fill: über Felder ab `index` verteilen
let chars = Array(onlyDigits.prefix(6 - index)) let chars = Array(onlyDigits.prefix(6 - index))
for (offset, ch) in chars.enumerated() { for (offset, ch) in chars.enumerated() {
@ -146,7 +164,7 @@ struct LoginView: View {
} }
} }
let nextFocus = min(index + chars.count, 5) let nextFocus = min(index + chars.count, 5)
focusedField = nextFocus advanceFocus(to: nextFocus)
if digits.allSatisfy({ !$0.isEmpty }) && !isLoading { if digits.allSatisfy({ !$0.isEmpty }) && !isLoading {
handleSubmit() handleSubmit()
} }
@ -157,20 +175,30 @@ struct LoginView: View {
// Backspace // Backspace
digits[index] = "" digits[index] = ""
if index > 0 { if index > 0 {
focusedField = index - 1 advanceFocus(to: index - 1)
} }
return 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 { if index < 5 {
focusedField = index + 1 advanceFocus(to: index + 1)
} else if isComplete && !isLoading { } else if isComplete && !isLoading {
// Letztes Feld gefüllt automatisch absenden // Letztes Feld gefüllt automatisch absenden
handleSubmit() 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() { private func handleSubmit() {
guard isComplete, !isLoading else { return } guard isComplete, !isLoading else { return }
let code = enteredCode let code = enteredCode

View File

@ -1,6 +1,13 @@
# Changelog # Changelog
All notable changes to rebreak-native will be documented in this file. 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 ## 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 - 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: { ios: {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE, bundleIdentifier: MAIN_BUNDLE,
buildNumber: "59", buildNumber: "67",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements. // com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: { android: {
package: "org.rebreak.app", package: "org.rebreak.app",
versionCode: 47, versionCode: 50,
adaptiveIcon: { adaptiveIcon: {
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem // Außenring) → adaptive-foreground.png ist das Logo auf transparentem
@ -84,6 +84,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"expo-localization", "expo-localization",
"expo-font", "expo-font",
"expo-web-browser", "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", "expo-build-properties",
{ {

View File

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

View File

@ -24,6 +24,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker'; 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 // 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 * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
@ -32,6 +33,7 @@ import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/Voice
import { DmChatBackground } from '../components/chat/DmChatBackground'; import { DmChatBackground } from '../components/chat/DmChatBackground';
import { FormSheet } from '../components/FormSheet'; import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useThemeStore } from '../stores/theme'; import { useThemeStore } from '../stores/theme';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
@ -65,6 +67,23 @@ type DmHistoryResponse = {
const GROUP_GAP_MS = 5 * 60 * 1000; 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() { export default function DmScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@ -87,9 +106,17 @@ export default function DmScreen() {
const scrollToBottom = useCallback((animated = false) => { const scrollToBottom = useCallback((animated = false) => {
flatListRef.current?.scrollToOffset({ offset: 999999, animated }); flatListRef.current?.scrollToOffset({ offset: 999999, animated });
}, []); }, []);
const [messages, setMessages] = useState<ChatMsg[]>([]); // Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null); // Konversation ist sofort sichtbar (kein Spinner, kein Flash).
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null); 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>( const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null, null,
); );
@ -102,6 +129,20 @@ export default function DmScreen() {
const [inputBarHeight, setInputBarHeight] = useState(60); const [inputBarHeight, setInputBarHeight] = useState(60);
const [infoSheetOpen, setInfoSheetOpen] = useState(false); const [infoSheetOpen, setInfoSheetOpen] = useState(false);
const [lightboxUri, setLightboxUri] = useState<string | null>(null); 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 // Voice recording
const [isVoiceRecording, setIsVoiceRecording] = useState(false); const [isVoiceRecording, setIsVoiceRecording] = useState(false);
@ -112,13 +153,18 @@ export default function DmScreen() {
const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const voiceStartTime = useRef(0); 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(() => { useEffect(() => {
setMessages([]); if (messagesUserId.current === userId) return;
setPartner(null);
partnerRef.current = null;
setReplyTo(null); 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 // Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen
useEffect(() => { useEffect(() => {
@ -147,16 +193,14 @@ export default function DmScreen() {
}, [queryClient]), }, [queryClient]),
); );
// Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug) // DM-History laden — stale-while-revalidate: gecachte Messages werden sofort
const { isLoading, isFetching } = useQuery({ // 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], queryKey: ['dm-history', userId],
queryFn: async () => { queryFn: async () => {
console.log('[dm] fetching history for partner', userId, 'me', myUserId); const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
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) => ({ const msgs: ChatMsg[] = data.messages.map((m: any) => ({
id: m.id, id: m.id,
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
@ -184,23 +228,27 @@ export default function DmScreen() {
reactions: m.reactions ?? [], reactions: m.reactions ?? [],
deleted: m.deleted ?? false, deleted: m.deleted ?? false,
})); }));
setMessages(msgs); return { partner: data.partner, messages: 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;
}
}, },
enabled: !!userId && !!myUserId, enabled: !!userId && !!myUserId,
staleTime: 0, staleTime: 30_000,
gcTime: 0, 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 // Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
useEffect(() => { useEffect(() => {
if (messages.length === 0) return; 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. // Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
const refetchHistory = useCallback(() => { const refetchHistory = useCallback(() => {
@ -243,6 +296,9 @@ export default function DmScreen() {
}, [queryClient, userId]); }, [queryClient, userId]);
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
// Typing-Indicator (ephemerer Broadcast, kein DB-Write)
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
async function pickImage() { async function pickImage() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) { if (!perm.granted) {
@ -326,6 +382,7 @@ export default function DmScreen() {
setAttachment(null); setAttachment(null);
setReplyTo(null); setReplyTo(null);
setSending(true); setSending(true);
sendStopTyping();
try { try {
let attachmentMeta: { url: string; type: string; name: string } | null = null; 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 { function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false; if (!a || !b) return false;
if (a.userId !== b.userId) 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; 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 ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View style={[styles.header, { backgroundColor: colors.bg }]}> <View style={[styles.header, { backgroundColor: colors.bg }]}>
@ -632,7 +731,7 @@ export default function DmScreen() {
<Text style={styles.headerName} numberOfLines={1}> <Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'} {partner?.nickname ?? '…'}
</Text> </Text>
{userId && <ChatHeaderStatus userId={userId} />} {userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@ -673,14 +772,20 @@ export default function DmScreen() {
onLike={toggleLike} onLike={toggleLike}
onReact={toggleReaction} onReact={toggleReaction}
onDelete={deleteMessage} onDelete={deleteMessage}
onOpenImage={(url) => setLightboxUri(url)} onOpenImage={openLightbox}
/> />
)} )}
keyExtractor={(m) => m.id} keyExtractor={(m) => m.id}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 0, paddingHorizontal: 0,
paddingTop: 12, 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} showsVerticalScrollIndicator={false}
keyboardDismissMode="interactive" keyboardDismissMode="interactive"
@ -763,7 +868,11 @@ export default function DmScreen() {
placeholder={t('chat.placeholder')} placeholder={t('chat.placeholder')}
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={inputText} value={inputText}
onChangeText={setInputText} onChangeText={(v) => {
setInputText(v);
if (v.trim().length > 0) sendTyping();
else sendStopTyping();
}}
multiline multiline
maxLength={2000} maxLength={2000}
returnKeyType="send" returnKeyType="send"
@ -804,7 +913,7 @@ export default function DmScreen() {
onClose={() => setInfoSheetOpen(false)} onClose={() => setInfoSheetOpen(false)}
partner={partner} partner={partner}
messages={messages} messages={messages}
onImagePress={(uri) => setLightboxUri(uri)} onImagePress={openLightbox}
onViewProfile={() => { onViewProfile={() => {
setInfoSheetOpen(false); setInfoSheetOpen(false);
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
@ -814,27 +923,58 @@ export default function DmScreen() {
/> />
{/* ── Lightbox ───────────────────────────────────────────────── */} {/* ── Lightbox ───────────────────────────────────────────────── */}
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={() => setLightboxUri(null)}> <Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
<TouchableOpacity <TouchableOpacity
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
activeOpacity={1} activeOpacity={1}
onPress={() => setLightboxUri(null)} onPress={closeLightbox}
> >
{lightboxUri && ( {lightboxUri && (
<Image <Image
source={{ uri: lightboxUri }} 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" contentFit="contain"
cachePolicy="memory-disk" cachePolicy="memory-disk"
/> />
)} )}
<TouchableOpacity <TouchableOpacity
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }} style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
onPress={() => setLightboxUri(null)} onPress={closeLightbox}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Ionicons name="close-circle" size={32} color="#fff" /> <Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity> </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> </TouchableOpacity>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>

View File

@ -41,6 +41,25 @@ function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang
return raw; 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 = { type Props = {
post: CommunityPost; post: CommunityPost;
onCommentPress: (postId: string) => void; onCommentPress: (postId: string) => void;
@ -269,7 +288,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
{/* Content — hidden for domain_vote (replaced by poll below) */} {/* Content — hidden for domain_vote (replaced by poll below) */}
{!!displayContent && post.category !== 'domain_vote' && ( {!!displayContent && post.category !== 'domain_vote' && (
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}> <Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
{displayContent} {renderWithMentions(displayContent, colors.brandOrange)}
</Text> </Text>
)} )}

View File

@ -33,21 +33,31 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
const [waveWidth, setWaveWidth] = useState(0); const [waveWidth, setWaveWidth] = useState(0);
const soundRef = useRef<Audio.Sound | null>(null); const soundRef = useRef<Audio.Sound | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 totalSeconds = useMemo(() => {
const [m, s] = (duration ?? '0:00').split(':').map(Number); const [m, s] = (duration ?? '0:00').split(':').map(Number);
return (m || 0) * 60 + (s || 0); return (m || 0) * 60 + (s || 0);
}, [duration]); }, [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 barHeights = useMemo(() => {
const seed = url.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); let s = url.split('').reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) >>> 0, 7) || 1;
// 80 bars, fixed 2dp width via space-between — screen-size-independent thinness const rand = () => {
return Array.from({ length: 80 }, (_, i) => { s = (s * 1103515245 + 12345) >>> 0;
const a = Math.abs(Math.sin((seed * 0.019 + i) * 2.1)); return s / 0xffffffff;
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 MAX_H = 24;
const env = Math.pow(Math.abs(Math.sin((seed * 0.011 + i) * 0.95)), 0.5); return Array.from({ length: 34 }, (_, i) => {
return Math.max(1.5, (a * 0.5 + b * 0.3 + c2 * 0.2) * env * 30); // 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]); }, [url]);
@ -70,15 +80,24 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true }); await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true });
const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true }); const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true });
soundRef.current = sound; soundRef.current = sound;
finishedRef.current = false;
sound.setOnPlaybackStatusUpdate((s) => { sound.setOnPlaybackStatusUpdate((s) => {
if (s.isLoaded && s.didJustFinish) { if (s.isLoaded && s.didJustFinish) {
finishedRef.current = true;
setIsPlaying(false); setIsPlaying(false);
setProgress(0); setProgress(0);
setCurrentTime(0); setCurrentTime(0);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } 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 { } else {
// Resume nach Pause: Position beibehalten
await soundRef.current.playAsync(); await soundRef.current.playAsync();
} }
setIsPlaying(true); setIsPlaying(true);
@ -126,7 +145,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
{barHeights.map((h, i) => ( {barHeights.map((h, i) => (
<View <View
key={i} 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> </View>
@ -666,8 +685,8 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
alignItems: 'center', alignItems: 'center',
}, },
content: { content: {
fontSize: 14, fontSize: 15,
lineHeight: 21, lineHeight: 22,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
}, },
footer: { 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 { useTranslation } from 'react-i18next';
import { useOnlineUsers } from '../../hooks/useOnlineUsers'; import { useOnlineUsers } from '../../hooks/useOnlineUsers';
import { useLastSeenBatch } from '../../hooks/useLastSeenBatch'; import { useLastSeenBatch } from '../../hooks/useLastSeenBatch';
type Props = { type Props = {
userId: string; 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 { function formatLastSeen(ts: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
const diff = Date.now() - new Date(ts).getTime(); const diff = Date.now() - new Date(ts).getTime();
if (diff < 60_000) return t('presence.just_now'); 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) }); 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 { t } = useTranslation();
const { isOnline } = useOnlineUsers(); // DM-Header zeigt den ECHTEN Presence-Status des Partners (wie WhatsApp) —
const lastSeenMap = useLastSeenBatch(isOnline(userId) ? [] : [userId]); // NICHT die following-gated `isOnline`-Variante aus dem Feed/Profil. Wer dir
const online = isOnline(userId); // 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) { if (online) {
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht // User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton. // als Farb-Signal). Neutraler `textMuted`-Grau-Ton.
return ( return (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}> <Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{t('presence.online')} {t('presence.online')}
</Text> </Text>
); );
@ -35,7 +86,7 @@ export function ChatHeaderStatus({ userId }: Props) {
if (!lastSeen) return null; if (!lastSeen) return null;
return ( return (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}> <Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
{formatLastSeen(lastSeen, t)} {formatLastSeen(lastSeen, t)}
</Text> </Text>
); );

View File

@ -1,11 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from "react";
import { apiFetch } from '../lib/api'; import { apiFetch } from "../lib/api";
import { resolveVipCountry } from './useWebContentDomains'; import { resolveVipCountry } from "./useWebContentDomains";
import { useBlockerStatsStore } from '../stores/blockerStats'; 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 = { export type CustomDomain = {
id: string; id: string;
@ -14,12 +14,17 @@ export type CustomDomain = {
status: DomainStatus; status: DomainStatus;
addedAt?: string; addedAt?: string;
postId?: string | null; 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; vipDeferUntil?: string | null;
vipEvictAt?: 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 * Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des
@ -43,19 +48,21 @@ export type AddDomainResult = {
export type Tier = { export type Tier = {
plan: Plan; plan: Plan;
domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam) domainLimit: number; // pro=10, legend=20 (web + mail gemeinsam)
refillEnabled: boolean; // pro/legend=true refillEnabled: boolean; // pro/legend=true
globalBlocklist: boolean; // pro/legend=true globalBlocklist: boolean; // pro/legend=true
canSubmit: boolean; // pro/legend=true canSubmit: boolean; // pro/legend=true
usedSlots: number; // active+submitted (NICHT approved/rejected) usedSlots: number; // active+submitted (NICHT approved/rejected)
atLimit: boolean; atLimit: boolean;
}; };
function deriveTier(plan: Plan, domains: CustomDomain[]): Tier { function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
// Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen. // Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen.
const limit = plan === 'legend' ? 20 : 10; const limit = plan === "legend" ? 20 : 10;
const refill = plan !== 'free'; const refill = plan !== "free";
const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; const usedSlots = domains.filter(
(d) => d.status === "active" || d.status === "submitted",
).length;
return { return {
plan, plan,
domainLimit: limit, domainLimit: limit,
@ -79,12 +86,15 @@ export type UseCustomDomainsReturn = {
refresh: () => Promise<void>; refresh: () => Promise<void>;
addDomain: ( addDomain: (
pattern: string, pattern: string,
kind?: 'web' | 'mail', kind?: "web" | "mail",
opts?: { addToVip?: boolean }, opts?: { addToVip?: boolean },
) => Promise<AddDomainResult>; ) => Promise<AddDomainResult>;
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>; submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
removeDomain: (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. */ /** Live-Validate (regex) ob string gültiger Domain-Name ist. */
isValidDomain: (s: string) => boolean; isValidDomain: (s: string) => boolean;
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */ /** 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 { export function normalizeDomain(input: string): string {
let s = input.trim().toLowerCase(); let s = input.trim().toLowerCase();
if (s.startsWith('https://')) s = s.slice(8); if (s.startsWith("https://")) s = s.slice(8);
else if (s.startsWith('http://')) s = s.slice(7); else if (s.startsWith("http://")) s = s.slice(7);
const slash = s.indexOf('/'); const slash = s.indexOf("/");
if (slash >= 0) s = s.slice(0, slash); 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; return s;
} }
@ -118,21 +128,67 @@ export function isValidDomain(input: string): boolean {
* bei Änderungen beide synchron halten. * bei Änderungen beide synchron halten.
*/ */
const PUBLIC_EMAIL_DOMAINS = new Set<string>([ const PUBLIC_EMAIL_DOMAINS = new Set<string>([
'gmail.com', 'googlemail.com', "gmail.com",
'icloud.com', 'me.com', 'mac.com', "googlemail.com",
'outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'hotmail.co.uk', "icloud.com",
'hotmail.fr', 'live.com', 'live.de', 'msn.com', "me.com",
'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'yahoo.fr', 'ymail.com', 'rocketmail.com', "mac.com",
'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com', 'web.de', "outlook.com",
'aol.com', 'aim.com', "outlook.de",
'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de', "hotmail.com",
'tuta.io', 'posteo.de', 'posteo.net', 'mailbox.org', 'hey.com', "hotmail.de",
't-online.de', 'freenet.de', 'arcor.de', "hotmail.co.uk",
'mail.com', 'mail.de', 'email.de', 'zoho.com', 'fastmail.com', 'fastmail.fm', "hotmail.fr",
'hushmail.com', "live.com",
'yandex.com', 'yandex.ru', 'mail.ru', "live.de",
'laposte.net', 'orange.fr', 'free.fr', 'sfr.fr', 'wanadoo.fr', "msn.com",
'qq.com', '163.com', '126.com', 'naver.com', 'daum.net', "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 { export function isPublicEmailDomain(domain: string): boolean {
@ -163,8 +219,13 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
// trifft bevor das Deploy landet. // trifft bevor das Deploy landet.
const res = await apiFetch< const res = await apiFetch<
| CustomDomain[] | 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 arr: CustomDomain[] = [];
let count: number | null = null; let count: number | null = null;
let limit: number | null = null; let limit: number | null = null;
@ -172,16 +233,18 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
arr = res; arr = res;
} else if (res) { } else if (res) {
arr = (res as any).items ?? (res as any).domains ?? []; arr = (res as any).items ?? (res as any).domains ?? [];
count = typeof (res as any).count === 'number' ? (res as any).count : null; count =
limit = typeof (res as any).limit === 'number' ? (res as any).limit : null; 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); setDomains(arr);
setApiCount(count); setApiCount(count);
setApiLimit(limit); setApiLimit(limit);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
console.error('[useCustomDomains] fetch failed:', e?.message ?? e); console.error("[useCustomDomains] fetch failed:", e?.message ?? e);
setError(e?.message ?? 'unknown'); setError(e?.message ?? "unknown");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -194,45 +257,56 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const addDomain = useCallback( const addDomain = useCallback(
async ( async (
input: string, input: string,
kind?: 'web' | 'mail', kind?: "web" | "mail",
opts?: { addToVip?: boolean }, opts?: { addToVip?: boolean },
): Promise<AddDomainResult> => { ): Promise<AddDomainResult> => {
const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web'); const resolvedKind: "web" | "mail" =
if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' }; kind ?? (input.includes("@") ? "mail" : "web");
if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' }; 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 // Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of
// Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit // Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit
// geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED. // geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED.
// Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot. // Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot.
if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) { if (
return { ok: false, error: 'limit_reached' }; !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 // Public-/Freemail-Domain (icloud.com, gmail.com …) hart ablehnen — web UND
// mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren. // mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren.
const domainToCheck = const domainToCheck =
resolvedKind === 'mail' && pattern.includes('@') resolvedKind === "mail" && pattern.includes("@")
? pattern.slice(pattern.lastIndexOf('@') + 1) ? pattern.slice(pattern.lastIndexOf("@") + 1)
: pattern; : 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 }; const body: Record<string, string | boolean> = { pattern };
if (kind !== undefined) body.kind = kind; if (kind !== undefined) body.kind = kind;
// Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes. // 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; if (opts?.addToVip) body.addToVip = true;
try { try {
const res = await apiFetch<any>('/api/custom-domains', { const res = await apiFetch<any>("/api/custom-domains", {
method: 'POST', method: "POST",
body, body,
}); });
if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true }; if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true };
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true }; if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true }; if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
await fetchDomains(); 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 }; return { ok: true, addedToVip: res?.addedToVip === true };
} catch (e: any) { } catch (e: any) {
return { ok: false, error: e?.message ?? 'add_failed' }; return { ok: false, error: e?.message ?? "add_failed" };
} }
}, },
[apiCount, apiLimit, fetchDomains], [apiCount, apiLimit, fetchDomains],
@ -241,16 +315,20 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const submitDomain = useCallback( const submitDomain = useCallback(
async (id: string) => { async (id: string) => {
const tier = deriveTier(plan, domains); 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 { 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 // Optimistisches lokales Update: Half-Donut im ProtectionDetailsSheet
// soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten. // soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten.
useBlockerStatsStore.getState().bumpMyInReview(1); useBlockerStatsStore.getState().bumpMyInReview(1);
await fetchDomains(); await fetchDomains();
return { ok: true }; return { ok: true };
} catch (e: any) { } catch (e: any) {
return { ok: false, error: e?.message ?? 'submit_failed' }; return { ok: false, error: e?.message ?? "submit_failed" };
} }
}, },
[plan, domains, fetchDomains], [plan, domains, fetchDomains],
@ -259,11 +337,11 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const removeDomain = useCallback( const removeDomain = useCallback(
async (id: string) => { async (id: string) => {
try { try {
await apiFetch(`/api/custom-domains/${id}`, { method: 'DELETE' }); await apiFetch(`/api/custom-domains/${id}`, { method: "DELETE" });
await fetchDomains(); await fetchDomains();
return { ok: true }; return { ok: true };
} catch (e: any) { } catch (e: any) {
return { ok: false, error: e?.message ?? 'remove_failed' }; return { ok: false, error: e?.message ?? "remove_failed" };
} }
}, },
[fetchDomains], [fetchDomains],
@ -272,14 +350,14 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
const submitVipSwap = useCallback( const submitVipSwap = useCallback(
async (newDomainId: string, evictedDomainId: string) => { async (newDomainId: string, evictedDomainId: string) => {
try { try {
await apiFetch('/api/custom-domains/vip-swap', { await apiFetch("/api/custom-domains/vip-swap", {
method: 'POST', method: "POST",
body: { newDomainId, evictedDomainId }, body: { newDomainId, evictedDomainId },
}); });
await fetchDomains(); await fetchDomains();
return { ok: true }; return { ok: true };
} catch (e: any) { } catch (e: any) {
return { ok: false, error: e?.message ?? 'vip_swap_failed' }; return { ok: false, error: e?.message ?? "vip_swap_failed" };
} }
}, },
[fetchDomains], [fetchDomains],
@ -291,8 +369,9 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
// Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert. // Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert.
const count: number = const count: number =
apiCount ?? apiCount ??
domains.filter((d) => d.status === 'active' || d.status === 'submitted').length; domains.filter((d) => d.status === "active" || d.status === "submitted")
const limit: number = apiLimit ?? (plan === 'legend' ? 20 : 10); .length;
const limit: number = apiLimit ?? (plan === "legend" ? 20 : 10);
return { return {
domains, 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": "صورة", "image_attachment": "صورة",
"file_attachment": "ملف", "file_attachment": "ملف",
"upload_failed": "فشل الرفع", "upload_failed": "فشل الرفع",
"save": "حفظ",
"image_saved": "تم حفظ الصورة في الصور",
"save_failed": "تعذّر حفظ الصورة",
"member_count": "%{n} أعضاء", "member_count": "%{n} أعضاء",
"member_count_online": "%{n} أعضاء · %{online} متصل", "member_count_online": "%{n} أعضاء · %{online} متصل",
"pending_request": "طلبات الانضمام", "pending_request": "طلبات الانضمام",

View File

@ -1104,6 +1104,9 @@
"image_attachment": "Bild", "image_attachment": "Bild",
"file_attachment": "Datei", "file_attachment": "Datei",
"upload_failed": "Upload fehlgeschlagen", "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": "%{n} Mitglieder",
"member_count_online": "%{n} Mitglieder · %{online} online", "member_count_online": "%{n} Mitglieder · %{online} online",
"pending_request": "Beitrittsanfragen", "pending_request": "Beitrittsanfragen",

View File

@ -1102,6 +1102,9 @@
"image_attachment": "Image", "image_attachment": "Image",
"file_attachment": "File", "file_attachment": "File",
"upload_failed": "Upload failed", "upload_failed": "Upload failed",
"save": "Save",
"image_saved": "Image saved to Photos",
"save_failed": "Could not save image",
"member_count": "%{n} members", "member_count": "%{n} members",
"member_count_online": "%{n} members · %{online} online", "member_count_online": "%{n} members · %{online} online",
"pending_request": "Join requests", "pending_request": "Join requests",

View File

@ -1022,6 +1022,9 @@
"image_attachment": "Image", "image_attachment": "Image",
"file_attachment": "Fichier", "file_attachment": "Fichier",
"upload_failed": "Échec du téléversement", "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": "%{n} membres",
"member_count_online": "%{n} membres · %{online} en ligne", "member_count_online": "%{n} membres · %{online} en ligne",
"pending_request": "Demandes d'adhésion", "pending_request": "Demandes d'adhésion",

View File

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

View File

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

View File

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

View File

@ -43,6 +43,7 @@
"expo-linking": "~8.0.12", "expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8", "expo-local-authentication": "~17.0.8",
"expo-localization": "~17.0.8", "expo-localization": "~17.0.8",
"expo-media-library": "~18.2.1",
"expo-modules-core": "^3.0.30", "expo-modules-core": "^3.0.30",
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-router": "~6.0.23", "expo-router": "~6.0.23",

View File

@ -1,5 +1,5 @@
import { create } from 'zustand'; import { create } from "zustand";
import { apiFetch } from '../lib/api'; import { apiFetch } from "../lib/api";
export type BlockerStats = { export type BlockerStats = {
current: number; current: number;
@ -61,7 +61,7 @@ type BlockerStatsState = {
let inFlight: Promise<void> | null = null; let inFlight: Promise<void> | null = null;
function asNumber(value: unknown): number { 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 { function normalizeStats(raw: RawStatsResponse): BlockerStats {
@ -76,12 +76,11 @@ function normalizeStats(raw: RawStatsResponse): BlockerStats {
asNumber(raw.mySubmissions?.pending); asNumber(raw.mySubmissions?.pending);
const approvedMine = const approvedMine =
asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.active);
asNumber(raw.mySubmissions?.active);
const history = Array.isArray(raw.history) const history = Array.isArray(raw.history)
? raw.history.map((h) => ({ ? raw.history.map((h) => ({
label: typeof h?.label === 'string' ? h.label : '', label: typeof h?.label === "string" ? h.label : "",
count: asNumber(h?.count), count: asNumber(h?.count),
})) }))
: []; : [];
@ -118,14 +117,14 @@ export const useBlockerStatsStore = create<BlockerStatsState>((set, get) => ({
inFlight = (async () => { inFlight = (async () => {
set((s) => ({ ...s, loading: true, error: null })); set((s) => ({ ...s, loading: true, error: null }));
try { try {
const raw = await apiFetch<RawStatsResponse>('/api/blocklist/stats'); const raw = await apiFetch<RawStatsResponse>("/api/blocklist/stats");
const stats = normalizeStats(raw ?? {}); const stats = normalizeStats(raw ?? {});
set({ stats, loading: false, error: null, fetchedAt: Date.now() }); set({ stats, loading: false, error: null, fetchedAt: Date.now() });
} catch (e: any) { } catch (e: any) {
set((s) => ({ set((s) => ({
...s, ...s,
loading: false, loading: false,
error: e?.message ?? 'stats_fetch_failed', error: e?.message ?? "stats_fetch_failed",
})); }));
} finally { } finally {
inFlight = null; inFlight = null;

View File

@ -29,9 +29,15 @@ Building Release AAB (gradlew bundleRelease)|307
Validating IPA (App-Store Connect)|83 Validating IPA (App-Store Connect)|83
Uploading zu App-Store Connect (TestFlight)|103 Uploading zu App-Store Connect (TestFlight)|103
Building Release AAB (gradlew bundleRelease)|370 Building Release AAB (gradlew bundleRelease)|370
Exporting App-Store IPA|25
Validating IPA (App-Store Connect)|115 Validating IPA (App-Store Connect)|115
Uploading zu App-Store Connect (TestFlight)|147 Uploading zu App-Store Connect (TestFlight)|147
Building Release AAB (gradlew bundleRelease)|320 Building Release AAB (gradlew bundleRelease)|320
Building xcarchive|223 Validating IPA (App-Store Connect)|105
Exporting Ad-Hoc IPA|20 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. // Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }], 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 // Supabase als external dep — nicht bundlen
externals: { externals: {
inline: [/^(?!@supabase\/supabase-js)/], inline: [/^(?!@supabase\/supabase-js)/],

View File

@ -1,12 +1,14 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { findMagicDeviceByToken } from "../../db/devices"; import { findMagicDeviceByToken } from "../../db/devices";
import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template";
/** /**
* GET /api/magic/profile.mobileconfig?token=<dnsToken> * GET /api/magic/profile.mobileconfig?token=<dnsToken>
* *
* Generiert personalisiertes DNS-Configuration-Profile für macOS. * Generiert personalisiertes DNS-Configuration-Profile für macOS.
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (via Nitro serverAssets * Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS
* unter baseName "mdm" eingebundelt siehe nitro.config.ts). * constant via backend/server/utils/magic-profile-template.ts überlebt
* jeden Build/Deploy ohne FS- oder serverAssets-Magic).
* *
* Ersetzt: * Ersetzt:
* - ServerURL: /dns-query /dns-query/{token} * - 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). // Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig).
const storage = useStorage("assets:server"); const template = MAGIC_PROFILE_TEMPLATE;
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",
});
}
// ServerURL ersetzen: /dns-query → /dns-query/{token} // ServerURL ersetzen: /dns-query → /dns-query/{token}
const personalizedProfile = template 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)) version: 14.3.0(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt': '@vueuse/nuxt':
specifier: ^14.2.1 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: nuxt:
specifier: 4.1.3 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) 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 version: 1.2.3
'@nuxt/devtools': '@nuxt/devtools':
specifier: latest 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: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@ -73,7 +73,7 @@ importers:
version: 1.2.3 version: 1.2.3
'@nuxt/fonts': '@nuxt/fonts':
specifier: ^0.11.4 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': '@nuxt/icon':
specifier: ^1.10.0 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)) 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)) version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt': '@vueuse/nuxt':
specifier: ^14.2.1 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: chart.js:
specifier: ^4.5.1 specifier: ^4.5.1
version: 4.5.1 version: 4.5.1
@ -113,7 +113,7 @@ importers:
devDependencies: devDependencies:
'@nuxt/devtools': '@nuxt/devtools':
specifier: latest 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: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@ -213,6 +213,9 @@ importers:
expo-localization: expo-localization:
specifier: ~17.0.8 specifier: ~17.0.8
version: 17.0.8(expo@54.0.34)(react@19.1.0) 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: expo-modules-core:
specifier: ^3.0.30 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) 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: peerDependencies:
vite: '>=6.0' vite: '>=6.0'
'@nuxt/devtools-kit@4.0.0-alpha.6': '@nuxt/devtools-kit@4.0.0-alpha.7':
resolution: {integrity: sha512-bmsjBu6SymaHeD6Bt5DBvUBuZ9MtYRflGL0RHEdbTt7cILVK4te1i/kwCshXAeckxla6tBsadl6rqyjmRFc69Q==} resolution: {integrity: sha512-Tgh+tSejh1GnZjdjgWyc4qCxskeX08XuSQBYMn/4SIV5AubeqYeAOMBD2qSmHOXjMCUpgyzpEhODcP3sgdgGRA==}
peerDependencies: peerDependencies:
vite: '>=6.0' vite: '>=6.0'
@ -2066,8 +2069,8 @@ packages:
peerDependencies: peerDependencies:
vite: '>=6.0' vite: '>=6.0'
'@nuxt/devtools@4.0.0-alpha.6': '@nuxt/devtools@4.0.0-alpha.7':
resolution: {integrity: sha512-5u6oB0UeBwCG6lIxLGcxqVwqTcmXiN4FiLCDJAQqi7rwJRkwTB7kdml9Nd6sraX2z5vuS3bsRyAav+8t6S3ryw==} resolution: {integrity: sha512-ZWPhutVNQwBx1AmjRbaVEvDEl6JT6bIF9s6v/lorMOhNNV99TdfOcv5o8kytdFNhkzzIsAyIFB09bK3gj0y61Q==}
peerDependencies: peerDependencies:
vite: '>=6.0' vite: '>=6.0'
@ -5622,6 +5625,12 @@ packages:
peerDependencies: peerDependencies:
expo: '*' expo: '*'
expo-media-library@18.2.1:
resolution: {integrity: sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA==}
peerDependencies:
expo: '*'
react-native: '*'
expo-modules-autolinking@3.0.25: expo-modules-autolinking@3.0.25:
resolution: {integrity: sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==} resolution: {integrity: sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==}
hasBin: true hasBin: true
@ -11445,7 +11454,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - 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: dependencies:
'@nuxt/kit': 4.4.6(magicast@0.5.3) '@nuxt/kit': 4.4.6(magicast@0.5.3)
tinyexec: 1.2.3 tinyexec: 1.2.3
@ -11505,9 +11514,9 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - 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: 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) '@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': 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)) '@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 - utf-8-validate
- vue - 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: 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/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) '@nuxt/kit': 3.21.4(magicast@0.5.3)
@ -14170,7 +14179,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- magicast - 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: dependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3) '@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3)) '@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
@ -15724,6 +15733,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: expo-modules-autolinking@3.0.25:
dependencies: dependencies:
'@expo/spawn-async': 1.7.2 '@expo/spawn-async': 1.7.2