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:
parent
d212452a5d
commit
8670b45351
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
8
apps/rebreak-native/NEXT_RELEASE.md
Normal file
8
apps/rebreak-native/NEXT_RELEASE.md
Normal 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
|
||||
@ -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",
|
||||
{
|
||||
|
||||
@ -232,6 +232,14 @@ function RootLayoutInner() {
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="magic"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'card',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="onboarding/index"
|
||||
options={{
|
||||
|
||||
@ -24,6 +24,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { apiFetch } from '../lib/api';
|
||||
@ -32,6 +33,7 @@ import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/Voice
|
||||
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
||||
import { FormSheet } from '../components/FormSheet';
|
||||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||||
import { useDmTyping } from '../hooks/useDmTyping';
|
||||
import { useColors } from '../lib/theme';
|
||||
import { useThemeStore } from '../stores/theme';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
@ -65,6 +67,23 @@ type DmHistoryResponse = {
|
||||
|
||||
const GROUP_GAP_MS = 5 * 60 * 1000;
|
||||
|
||||
type DmData = {
|
||||
partner: DmHistoryResponse['partner'];
|
||||
messages: ChatMsg[];
|
||||
};
|
||||
|
||||
// Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras
|
||||
// (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch
|
||||
// nicht kannte) bleiben erhalten und werden nach createdAt einsortiert.
|
||||
function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] {
|
||||
const serverIds = new Set(server.map((m) => m.id));
|
||||
const extras = local.filter((m) => !serverIds.has(m.id));
|
||||
if (extras.length === 0) return server;
|
||||
return [...server, ...extras].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export default function DmScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@ -87,9 +106,17 @@ export default function DmScreen() {
|
||||
const scrollToBottom = useCallback((animated = false) => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 999999, animated });
|
||||
}, []);
|
||||
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
||||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
||||
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
||||
// Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
|
||||
// Konversation ist sofort sichtbar (kein Spinner, kein Flash).
|
||||
const [messages, setMessages] = useState<ChatMsg[]>(
|
||||
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.messages ?? [],
|
||||
);
|
||||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(
|
||||
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.partner ?? null,
|
||||
);
|
||||
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(partner);
|
||||
// userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard).
|
||||
const messagesUserId = useRef<string | undefined>(userId);
|
||||
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
||||
null,
|
||||
);
|
||||
@ -102,6 +129,20 @@ export default function DmScreen() {
|
||||
const [inputBarHeight, setInputBarHeight] = useState(60);
|
||||
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
|
||||
const [lightboxUri, setLightboxUri] = useState<string | null>(null);
|
||||
// Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um
|
||||
// den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die
|
||||
// sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats.
|
||||
const [lightboxRatio, setLightboxRatio] = useState<number | null>(null);
|
||||
const [savingImage, setSavingImage] = useState(false);
|
||||
|
||||
const openLightbox = useCallback((uri: string) => {
|
||||
setLightboxRatio(null);
|
||||
setLightboxUri(uri);
|
||||
}, []);
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxUri(null);
|
||||
setLightboxRatio(null);
|
||||
}, []);
|
||||
|
||||
// Voice recording
|
||||
const [isVoiceRecording, setIsVoiceRecording] = useState(false);
|
||||
@ -112,13 +153,18 @@ export default function DmScreen() {
|
||||
const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const voiceStartTime = useRef(0);
|
||||
|
||||
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
|
||||
// Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft
|
||||
// wegräumen und sofort auf den Cache der neuen Konversation umschalten:
|
||||
// vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading).
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setPartner(null);
|
||||
partnerRef.current = null;
|
||||
if (messagesUserId.current === userId) return;
|
||||
setReplyTo(null);
|
||||
}, [userId]);
|
||||
const cached = queryClient.getQueryData<DmData>(['dm-history', userId]);
|
||||
setMessages(cached?.messages ?? []);
|
||||
setPartner(cached?.partner ?? null);
|
||||
partnerRef.current = cached?.partner ?? null;
|
||||
messagesUserId.current = userId;
|
||||
}, [userId, queryClient]);
|
||||
|
||||
// Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen
|
||||
useEffect(() => {
|
||||
@ -147,16 +193,14 @@ export default function DmScreen() {
|
||||
}, [queryClient]),
|
||||
);
|
||||
|
||||
// Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug)
|
||||
const { isLoading, isFetching } = useQuery({
|
||||
// DM-History laden — stale-while-revalidate: gecachte Messages werden sofort
|
||||
// gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch
|
||||
// gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass
|
||||
// ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen.
|
||||
const { data: historyData, isLoading, isFetching } = useQuery<DmData>({
|
||||
queryKey: ['dm-history', userId],
|
||||
queryFn: async () => {
|
||||
console.log('[dm] fetching history for partner', userId, 'me', myUserId);
|
||||
try {
|
||||
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
||||
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
|
||||
setPartner(data.partner);
|
||||
partnerRef.current = data.partner;
|
||||
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
||||
@ -184,23 +228,27 @@ export default function DmScreen() {
|
||||
reactions: m.reactions ?? [],
|
||||
deleted: m.deleted ?? false,
|
||||
}));
|
||||
setMessages(msgs);
|
||||
// Dreistufiges Scroll-to-bottom: rAF + 100ms + 300ms deckt
|
||||
// Fälle ab wo Bilder nachgeladen werden und Content-Höhe wächst.
|
||||
requestAnimationFrame(() => scrollToBottom(false));
|
||||
setTimeout(() => scrollToBottom(false), 100);
|
||||
setTimeout(() => scrollToBottom(false), 300);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
console.error('[dm] history fetch failed:', err?.message ?? err);
|
||||
throw err;
|
||||
}
|
||||
return { partner: data.partner, messages: msgs };
|
||||
},
|
||||
enabled: !!userId && !!myUserId,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
staleTime: 30_000,
|
||||
gcTime: 30 * 60_000,
|
||||
});
|
||||
|
||||
// Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit
|
||||
// Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der
|
||||
// Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs).
|
||||
useEffect(() => {
|
||||
if (!historyData) return;
|
||||
setPartner(historyData.partner);
|
||||
partnerRef.current = historyData.partner;
|
||||
setMessages((prev) => {
|
||||
const base = messagesUserId.current === userId ? prev : [];
|
||||
messagesUserId.current = userId;
|
||||
return mergeMessages(historyData.messages, base);
|
||||
});
|
||||
}, [historyData, userId]);
|
||||
|
||||
// Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
@ -234,8 +282,13 @@ export default function DmScreen() {
|
||||
},
|
||||
];
|
||||
});
|
||||
// Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als
|
||||
// gelesen markieren. markDmsAsRead läuft nur im History-GET, also den
|
||||
// invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge
|
||||
// hängen, weil dm-conversations die Live-Message als unread zählt.
|
||||
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
|
||||
},
|
||||
[myUserId],
|
||||
[myUserId, queryClient, userId],
|
||||
);
|
||||
// Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
|
||||
const refetchHistory = useCallback(() => {
|
||||
@ -243,6 +296,9 @@ export default function DmScreen() {
|
||||
}, [queryClient, userId]);
|
||||
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
|
||||
|
||||
// Typing-Indicator (ephemerer Broadcast, kein DB-Write)
|
||||
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
|
||||
|
||||
async function pickImage() {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
@ -326,6 +382,7 @@ export default function DmScreen() {
|
||||
setAttachment(null);
|
||||
setReplyTo(null);
|
||||
setSending(true);
|
||||
sendStopTyping();
|
||||
|
||||
try {
|
||||
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
||||
@ -601,12 +658,54 @@ export default function DmScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst
|
||||
// lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht.
|
||||
async function saveImage(uri: string) {
|
||||
if (savingImage) return;
|
||||
try {
|
||||
setSavingImage(true);
|
||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
|
||||
return;
|
||||
}
|
||||
let localUri = uri;
|
||||
if (!uri.startsWith('file://')) {
|
||||
const ext = uri.split('?')[0].split('.').pop() || 'jpg';
|
||||
const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`;
|
||||
const res = await FileSystem.downloadAsync(uri, target);
|
||||
localUri = res.uri;
|
||||
}
|
||||
await MediaLibrary.saveToLibraryAsync(localUri);
|
||||
Alert.alert(t('chat.image_saved'));
|
||||
} catch (err: any) {
|
||||
Alert.alert(t('chat.save_failed'), err?.message ?? '');
|
||||
} finally {
|
||||
setSavingImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
if (a.userId !== b.userId) return false;
|
||||
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
|
||||
}
|
||||
|
||||
// Lightbox-Bildmaße: in die Bildschirmfläche einpassen, Seitenverhältnis wahren.
|
||||
const lbWin = Dimensions.get('window');
|
||||
const lbMaxW = lbWin.width - 24;
|
||||
const lbMaxH = lbWin.height * 0.78;
|
||||
let lbW = lbMaxW;
|
||||
let lbH = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
|
||||
if (lightboxRatio) {
|
||||
lbW = lbMaxW;
|
||||
lbH = lbMaxW / lightboxRatio;
|
||||
if (lbH > lbMaxH) {
|
||||
lbH = lbMaxH;
|
||||
lbW = lbMaxH * lightboxRatio;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={[styles.header, { backgroundColor: colors.bg }]}>
|
||||
@ -632,7 +731,7 @@ export default function DmScreen() {
|
||||
<Text style={styles.headerName} numberOfLines={1}>
|
||||
{partner?.nickname ?? '…'}
|
||||
</Text>
|
||||
{userId && <ChatHeaderStatus userId={userId} />}
|
||||
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@ -673,14 +772,20 @@ export default function DmScreen() {
|
||||
onLike={toggleLike}
|
||||
onReact={toggleReaction}
|
||||
onDelete={deleteMessage}
|
||||
onOpenImage={(url) => setLightboxUri(url)}
|
||||
onOpenImage={openLightbox}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(m) => m.id}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 12,
|
||||
paddingBottom: inputBarHeight + 12,
|
||||
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
|
||||
// der Viewport schrumpft NICHT → Clearance = keyboardHeight.
|
||||
// Tastatur zu: die Input-Bar (KeyboardStickyView) sitzt in ihrem
|
||||
// eigenen Layout-Slot UNTER der FlatList, ihre Höhe ist also schon
|
||||
// abgedeckt — hier nur ein knapper WA-Style-Gap, sonst „schwebt" die
|
||||
// letzte Nachricht beim Initial-Load zu hoch über der Bar.
|
||||
paddingBottom: keyboardVisible ? keyboardHeight + 4 : 8,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode="interactive"
|
||||
@ -763,7 +868,11 @@ export default function DmScreen() {
|
||||
placeholder={t('chat.placeholder')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onChangeText={(v) => {
|
||||
setInputText(v);
|
||||
if (v.trim().length > 0) sendTyping();
|
||||
else sendStopTyping();
|
||||
}}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
returnKeyType="send"
|
||||
@ -804,7 +913,7 @@ export default function DmScreen() {
|
||||
onClose={() => setInfoSheetOpen(false)}
|
||||
partner={partner}
|
||||
messages={messages}
|
||||
onImagePress={(uri) => setLightboxUri(uri)}
|
||||
onImagePress={openLightbox}
|
||||
onViewProfile={() => {
|
||||
setInfoSheetOpen(false);
|
||||
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
|
||||
@ -814,27 +923,58 @@ export default function DmScreen() {
|
||||
/>
|
||||
|
||||
{/* ── Lightbox ───────────────────────────────────────────────── */}
|
||||
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={() => setLightboxUri(null)}>
|
||||
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
|
||||
activeOpacity={1}
|
||||
onPress={() => setLightboxUri(null)}
|
||||
onPress={closeLightbox}
|
||||
>
|
||||
{lightboxUri && (
|
||||
<Image
|
||||
source={{ uri: lightboxUri }}
|
||||
style={{ width: Dimensions.get('window').width, height: Dimensions.get('window').width }}
|
||||
onLoad={(e) => {
|
||||
const s = e.source;
|
||||
if (s?.width && s?.height) setLightboxRatio(s.width / s.height);
|
||||
}}
|
||||
style={{ width: lbW, height: lbH, borderRadius: 16 }}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
|
||||
onPress={() => setLightboxUri(null)}
|
||||
onPress={closeLightbox}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="close-circle" size={32} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
{/* Sichern */}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 54,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||
}}
|
||||
onPress={() => lightboxUri && saveImage(lightboxUri)}
|
||||
disabled={savingImage}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{savingImage ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={20} color="#fff" />
|
||||
)}
|
||||
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{t('chat.save')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
|
||||
@ -41,6 +41,25 @@ function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang
|
||||
return raw;
|
||||
}
|
||||
|
||||
// @mention-Tokens (z.B. das @Hamed in Lyras Danke-Posts bei Domain-Approval)
|
||||
// farblich hervorheben, damit klar wird dass eine Person erwähnt ist. Regex:
|
||||
// @ + Buchstabe + Buchstaben/Ziffern/_ (unicode-aware → matcht auch arabische
|
||||
// Nicknames). split() mit Capture-Group behält die Tokens im Ergebnis-Array.
|
||||
const MENTION_RE = /(@[\p{L}][\p{L}\p{N}_]*)/gu;
|
||||
function renderWithMentions(text: string, accent: string) {
|
||||
if (!text.includes('@')) return text;
|
||||
const parts = text.split(MENTION_RE);
|
||||
return parts.map((part, i) =>
|
||||
i % 2 === 1 ? (
|
||||
<Text key={i} style={{ color: accent, fontFamily: 'Nunito_700Bold' }}>
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
post: CommunityPost;
|
||||
onCommentPress: (postId: string) => void;
|
||||
@ -269,7 +288,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
||||
{/* Content — hidden for domain_vote (replaced by poll below) */}
|
||||
{!!displayContent && post.category !== 'domain_vote' && (
|
||||
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular', lineHeight: 21 }}>
|
||||
{displayContent}
|
||||
{renderWithMentions(displayContent, colors.brandOrange)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@ -33,21 +33,31 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
||||
const [waveWidth, setWaveWidth] = useState(0);
|
||||
const soundRef = useRef<Audio.Sound | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// Merkt sich ob die Wiedergabe komplett durchlief — dann muss der nächste
|
||||
// Play von vorne (replayAsync) statt am Ende-stehengebliebenen playAsync.
|
||||
const finishedRef = useRef(false);
|
||||
|
||||
const totalSeconds = useMemo(() => {
|
||||
const [m, s] = (duration ?? '0:00').split(':').map(Number);
|
||||
return (m || 0) * 60 + (s || 0);
|
||||
}, [duration]);
|
||||
|
||||
// WhatsApp-Look: ~34 dickere Balken mit deutlich variierender Höhe statt
|
||||
// 80 gleichförmiger dünner Striche (sah „hardcodiert" aus). Deterministischer
|
||||
// LCG-PRNG (aus URL geseedet) → pro Sprachnachricht stabil, aber natürliche
|
||||
// Amplituden-Streuung wie eine echte Sprach-Wellenform.
|
||||
const barHeights = useMemo(() => {
|
||||
const seed = url.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
// 80 bars, fixed 2dp width via space-between — screen-size-independent thinness
|
||||
return Array.from({ length: 80 }, (_, i) => {
|
||||
const a = Math.abs(Math.sin((seed * 0.019 + i) * 2.1));
|
||||
const b = Math.abs(Math.sin((seed * 0.037 + i) * 3.7));
|
||||
const c2 = Math.abs(Math.sin((seed * 0.073 + i) * 6.3));
|
||||
const env = Math.pow(Math.abs(Math.sin((seed * 0.011 + i) * 0.95)), 0.5);
|
||||
return Math.max(1.5, (a * 0.5 + b * 0.3 + c2 * 0.2) * env * 30);
|
||||
let s = url.split('').reduce((acc, c) => (acc * 31 + c.charCodeAt(0)) >>> 0, 7) || 1;
|
||||
const rand = () => {
|
||||
s = (s * 1103515245 + 12345) >>> 0;
|
||||
return s / 0xffffffff;
|
||||
};
|
||||
const MAX_H = 24;
|
||||
return Array.from({ length: 34 }, (_, i) => {
|
||||
// Sanfte Sprech-Hüllkurve (steigt/fällt) × Zufalls-Spitzen
|
||||
const env = 0.45 + 0.55 * Math.abs(Math.sin((i / 34) * Math.PI * 2.3 + (s % 5)));
|
||||
const peak = 0.2 + 0.8 * rand();
|
||||
return Math.max(3, peak * env * MAX_H);
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
@ -70,15 +80,24 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
||||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false, playsInSilentModeIOS: true });
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: url }, { shouldPlay: true });
|
||||
soundRef.current = sound;
|
||||
finishedRef.current = false;
|
||||
sound.setOnPlaybackStatusUpdate((s) => {
|
||||
if (s.isLoaded && s.didJustFinish) {
|
||||
finishedRef.current = true;
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
}
|
||||
});
|
||||
} else if (finishedRef.current) {
|
||||
// Nach komplettem Durchlauf: von vorne abspielen (Position steht am Ende)
|
||||
finishedRef.current = false;
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
await soundRef.current.replayAsync();
|
||||
} else {
|
||||
// Resume nach Pause: Position beibehalten
|
||||
await soundRef.current.playAsync();
|
||||
}
|
||||
setIsPlaying(true);
|
||||
@ -126,7 +145,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
||||
{barHeights.map((h, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{ width: 2, height: h, borderRadius: 1, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
|
||||
style={{ width: 3, height: h, borderRadius: 1.5, backgroundColor: i < playedCount ? playedBarColor : unplayedBarColor }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@ -666,8 +685,8 @@ function makeStyles(colors: ReturnType<typeof useColors>) {
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
},
|
||||
footer: {
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { Text } from 'react-native';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Text, View, Animated, Easing } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOnlineUsers } from '../../hooks/useOnlineUsers';
|
||||
import { useLastSeenBatch } from '../../hooks/useLastSeenBatch';
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
/** Partner tippt gerade → überschreibt Online/Last-Seen mit „schreibt …". */
|
||||
typing?: boolean;
|
||||
};
|
||||
|
||||
const STATUS_COLOR = '#a3a3a3';
|
||||
|
||||
function formatLastSeen(ts: string, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return t('presence.just_now');
|
||||
@ -15,17 +20,63 @@ function formatLastSeen(ts: string, t: (key: string, opts?: Record<string, unkno
|
||||
return t('presence.days_ago', { days: Math.floor(diff / 86_400_000) });
|
||||
}
|
||||
|
||||
export function ChatHeaderStatus({ userId }: Props) {
|
||||
/** Drei pulsierende Punkte (WA/Insta-Style) neben dem „schreibt"-Text. */
|
||||
function TypingDots() {
|
||||
const dots = useRef([new Animated.Value(0.3), new Animated.Value(0.3), new Animated.Value(0.3)]).current;
|
||||
|
||||
useEffect(() => {
|
||||
const loops = dots.map((d, i) =>
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(i * 160),
|
||||
Animated.timing(d, { toValue: 1, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
Animated.timing(d, { toValue: 0.3, duration: 320, easing: Easing.inOut(Easing.ease), useNativeDriver: true }),
|
||||
Animated.delay((dots.length - 1 - i) * 160),
|
||||
]),
|
||||
),
|
||||
);
|
||||
loops.forEach((l) => l.start());
|
||||
return () => loops.forEach((l) => l.stop());
|
||||
}, [dots]);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginLeft: 4, gap: 2 }}>
|
||||
{dots.map((d, i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={{ width: 3, height: 3, borderRadius: 1.5, backgroundColor: STATUS_COLOR, opacity: d }}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatHeaderStatus({ userId, typing }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { isOnline } = useOnlineUsers();
|
||||
const lastSeenMap = useLastSeenBatch(isOnline(userId) ? [] : [userId]);
|
||||
const online = isOnline(userId);
|
||||
// DM-Header zeigt den ECHTEN Presence-Status des Partners (wie WhatsApp) —
|
||||
// NICHT die following-gated `isOnline`-Variante aus dem Feed/Profil. Wer dir
|
||||
// schreibt, sieht ohnehin via Typing-Indicator dass du da bist. `onlineUserIds`
|
||||
// ist der rohe Presence-Set → updatet live über den Presence-Sync-Channel.
|
||||
const { onlineUserIds } = useOnlineUsers();
|
||||
const online = onlineUserIds.has(userId);
|
||||
const lastSeenMap = useLastSeenBatch(online ? [] : [userId]);
|
||||
|
||||
if (typing) {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
||||
{t('presence.typing')}
|
||||
</Text>
|
||||
<TypingDots />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (online) {
|
||||
// User-Wunsch: „Online"-Text zeigen, aber NICHT grün (Dot im Avatar reicht
|
||||
// als Farb-Signal). Neutraler `textMuted`-Grau-Ton.
|
||||
return (
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
||||
{t('presence.online')}
|
||||
</Text>
|
||||
);
|
||||
@ -35,7 +86,7 @@ export function ChatHeaderStatus({ userId }: Props) {
|
||||
if (!lastSeen) return null;
|
||||
|
||||
return (
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: STATUS_COLOR }}>
|
||||
{formatLastSeen(lastSeen, t)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { resolveVipCountry } from './useWebContentDomains';
|
||||
import { useBlockerStatsStore } from '../stores/blockerStats';
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { resolveVipCountry } from "./useWebContentDomains";
|
||||
import { useBlockerStatsStore } from "../stores/blockerStats";
|
||||
|
||||
export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected';
|
||||
export type DomainStatus = "active" | "submitted" | "approved" | "rejected";
|
||||
|
||||
export type EntryKind = 'web' | 'mail_domain' | 'mail_display_name';
|
||||
export type EntryKind = "web" | "mail_domain" | "mail_display_name";
|
||||
|
||||
export type CustomDomain = {
|
||||
id: string;
|
||||
@ -14,12 +14,17 @@ export type CustomDomain = {
|
||||
status: DomainStatus;
|
||||
addedAt?: string;
|
||||
postId?: string | null;
|
||||
submission?: { id: string; yesVotes: number; noVotes: number; status: string } | null;
|
||||
submission?: {
|
||||
id: string;
|
||||
yesVotes: number;
|
||||
noVotes: number;
|
||||
status: string;
|
||||
} | null;
|
||||
vipDeferUntil?: string | null;
|
||||
vipEvictAt?: string | null;
|
||||
};
|
||||
|
||||
export type Plan = 'free' | 'pro' | 'legend';
|
||||
export type Plan = "free" | "pro" | "legend";
|
||||
|
||||
/**
|
||||
* Ergebnis von addDomain. Neben `ok` transportiert es die 3-Fall-Logik des
|
||||
@ -53,9 +58,11 @@ export type Tier = {
|
||||
|
||||
function deriveTier(plan: Plan, domains: CustomDomain[]): Tier {
|
||||
// Slots: EIN gemeinsamer Pool für web + mail. Free-Tier ist entfallen.
|
||||
const limit = plan === 'legend' ? 20 : 10;
|
||||
const refill = plan !== 'free';
|
||||
const usedSlots = domains.filter((d) => d.status === 'active' || d.status === 'submitted').length;
|
||||
const limit = plan === "legend" ? 20 : 10;
|
||||
const refill = plan !== "free";
|
||||
const usedSlots = domains.filter(
|
||||
(d) => d.status === "active" || d.status === "submitted",
|
||||
).length;
|
||||
return {
|
||||
plan,
|
||||
domainLimit: limit,
|
||||
@ -79,12 +86,15 @@ export type UseCustomDomainsReturn = {
|
||||
refresh: () => Promise<void>;
|
||||
addDomain: (
|
||||
pattern: string,
|
||||
kind?: 'web' | 'mail',
|
||||
kind?: "web" | "mail",
|
||||
opts?: { addToVip?: boolean },
|
||||
) => Promise<AddDomainResult>;
|
||||
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
submitVipSwap: (newDomainId: string, evictedDomainId: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
submitVipSwap: (
|
||||
newDomainId: string,
|
||||
evictedDomainId: string,
|
||||
) => Promise<{ ok: boolean; error?: string }>;
|
||||
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
|
||||
isValidDomain: (s: string) => boolean;
|
||||
/** Normalize: lowercase, http(s)://, /path stripping, www. weg. */
|
||||
@ -95,11 +105,11 @@ const DOMAIN_REGEX = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
|
||||
|
||||
export function normalizeDomain(input: string): string {
|
||||
let s = input.trim().toLowerCase();
|
||||
if (s.startsWith('https://')) s = s.slice(8);
|
||||
else if (s.startsWith('http://')) s = s.slice(7);
|
||||
const slash = s.indexOf('/');
|
||||
if (s.startsWith("https://")) s = s.slice(8);
|
||||
else if (s.startsWith("http://")) s = s.slice(7);
|
||||
const slash = s.indexOf("/");
|
||||
if (slash >= 0) s = s.slice(0, slash);
|
||||
if (s.startsWith('www.')) s = s.slice(4);
|
||||
if (s.startsWith("www.")) s = s.slice(4);
|
||||
return s;
|
||||
}
|
||||
|
||||
@ -118,21 +128,67 @@ export function isValidDomain(input: string): boolean {
|
||||
* — bei Änderungen beide synchron halten.
|
||||
*/
|
||||
const PUBLIC_EMAIL_DOMAINS = new Set<string>([
|
||||
'gmail.com', 'googlemail.com',
|
||||
'icloud.com', 'me.com', 'mac.com',
|
||||
'outlook.com', 'outlook.de', 'hotmail.com', 'hotmail.de', 'hotmail.co.uk',
|
||||
'hotmail.fr', 'live.com', 'live.de', 'msn.com',
|
||||
'yahoo.com', 'yahoo.de', 'yahoo.co.uk', 'yahoo.fr', 'ymail.com', 'rocketmail.com',
|
||||
'gmx.de', 'gmx.net', 'gmx.at', 'gmx.ch', 'gmx.com', 'web.de',
|
||||
'aol.com', 'aim.com',
|
||||
'proton.me', 'protonmail.com', 'pm.me', 'tutanota.com', 'tutanota.de',
|
||||
'tuta.io', 'posteo.de', 'posteo.net', 'mailbox.org', 'hey.com',
|
||||
't-online.de', 'freenet.de', 'arcor.de',
|
||||
'mail.com', 'mail.de', 'email.de', 'zoho.com', 'fastmail.com', 'fastmail.fm',
|
||||
'hushmail.com',
|
||||
'yandex.com', 'yandex.ru', 'mail.ru',
|
||||
'laposte.net', 'orange.fr', 'free.fr', 'sfr.fr', 'wanadoo.fr',
|
||||
'qq.com', '163.com', '126.com', 'naver.com', 'daum.net',
|
||||
"gmail.com",
|
||||
"googlemail.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com",
|
||||
"outlook.com",
|
||||
"outlook.de",
|
||||
"hotmail.com",
|
||||
"hotmail.de",
|
||||
"hotmail.co.uk",
|
||||
"hotmail.fr",
|
||||
"live.com",
|
||||
"live.de",
|
||||
"msn.com",
|
||||
"yahoo.com",
|
||||
"yahoo.de",
|
||||
"yahoo.co.uk",
|
||||
"yahoo.fr",
|
||||
"ymail.com",
|
||||
"rocketmail.com",
|
||||
"gmx.de",
|
||||
"gmx.net",
|
||||
"gmx.at",
|
||||
"gmx.ch",
|
||||
"gmx.com",
|
||||
"web.de",
|
||||
"aol.com",
|
||||
"aim.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"pm.me",
|
||||
"tutanota.com",
|
||||
"tutanota.de",
|
||||
"tuta.io",
|
||||
"posteo.de",
|
||||
"posteo.net",
|
||||
"mailbox.org",
|
||||
"hey.com",
|
||||
"t-online.de",
|
||||
"freenet.de",
|
||||
"arcor.de",
|
||||
"mail.com",
|
||||
"mail.de",
|
||||
"email.de",
|
||||
"zoho.com",
|
||||
"fastmail.com",
|
||||
"fastmail.fm",
|
||||
"hushmail.com",
|
||||
"yandex.com",
|
||||
"yandex.ru",
|
||||
"mail.ru",
|
||||
"laposte.net",
|
||||
"orange.fr",
|
||||
"free.fr",
|
||||
"sfr.fr",
|
||||
"wanadoo.fr",
|
||||
"qq.com",
|
||||
"163.com",
|
||||
"126.com",
|
||||
"naver.com",
|
||||
"daum.net",
|
||||
]);
|
||||
|
||||
export function isPublicEmailDomain(domain: string): boolean {
|
||||
@ -163,8 +219,13 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
// trifft bevor das Deploy landet.
|
||||
const res = await apiFetch<
|
||||
| CustomDomain[]
|
||||
| { items?: CustomDomain[]; domains?: CustomDomain[]; count?: number; limit?: number }
|
||||
>('/api/custom-domains');
|
||||
| {
|
||||
items?: CustomDomain[];
|
||||
domains?: CustomDomain[];
|
||||
count?: number;
|
||||
limit?: number;
|
||||
}
|
||||
>("/api/custom-domains");
|
||||
let arr: CustomDomain[] = [];
|
||||
let count: number | null = null;
|
||||
let limit: number | null = null;
|
||||
@ -172,16 +233,18 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
arr = res;
|
||||
} else if (res) {
|
||||
arr = (res as any).items ?? (res as any).domains ?? [];
|
||||
count = typeof (res as any).count === 'number' ? (res as any).count : null;
|
||||
limit = typeof (res as any).limit === 'number' ? (res as any).limit : null;
|
||||
count =
|
||||
typeof (res as any).count === "number" ? (res as any).count : null;
|
||||
limit =
|
||||
typeof (res as any).limit === "number" ? (res as any).limit : null;
|
||||
}
|
||||
setDomains(arr);
|
||||
setApiCount(count);
|
||||
setApiLimit(limit);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error('[useCustomDomains] fetch failed:', e?.message ?? e);
|
||||
setError(e?.message ?? 'unknown');
|
||||
console.error("[useCustomDomains] fetch failed:", e?.message ?? e);
|
||||
setError(e?.message ?? "unknown");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -194,45 +257,56 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
const addDomain = useCallback(
|
||||
async (
|
||||
input: string,
|
||||
kind?: 'web' | 'mail',
|
||||
kind?: "web" | "mail",
|
||||
opts?: { addToVip?: boolean },
|
||||
): Promise<AddDomainResult> => {
|
||||
const resolvedKind: 'web' | 'mail' = kind ?? (input.includes('@') ? 'mail' : 'web');
|
||||
if (resolvedKind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
|
||||
if (resolvedKind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' };
|
||||
const resolvedKind: "web" | "mail" =
|
||||
kind ?? (input.includes("@") ? "mail" : "web");
|
||||
if (resolvedKind === "web" && !isValidDomain(input))
|
||||
return { ok: false, error: "invalid_domain" };
|
||||
if (resolvedKind === "mail" && !input.trim())
|
||||
return { ok: false, error: "invalid_pattern" };
|
||||
// Slot-Limit-Vorabcheck gegen den Backend-count/limit (Single Source of
|
||||
// Truth — EIN gemeinsamer Pool). Wenn die API noch keine count/limit
|
||||
// geliefert hat → skip, das Backend rejected dann mit LIMIT_REACHED.
|
||||
// Entfällt bei addToVip: 'approved'-Einträge belegen keinen Slot.
|
||||
if (!opts?.addToVip && apiCount != null && apiLimit != null && apiCount >= apiLimit) {
|
||||
return { ok: false, error: 'limit_reached' };
|
||||
if (
|
||||
!opts?.addToVip &&
|
||||
apiCount != null &&
|
||||
apiLimit != null &&
|
||||
apiCount >= apiLimit
|
||||
) {
|
||||
return { ok: false, error: "limit_reached" };
|
||||
}
|
||||
const pattern = resolvedKind === 'web' ? normalizeDomain(input) : input.trim();
|
||||
const pattern =
|
||||
resolvedKind === "web" ? normalizeDomain(input) : input.trim();
|
||||
// Public-/Freemail-Domain (icloud.com, gmail.com …) hart ablehnen — web UND
|
||||
// mail. Sonst würde das Blocken die gesamte Mail/Webmail des Users sperren.
|
||||
const domainToCheck =
|
||||
resolvedKind === 'mail' && pattern.includes('@')
|
||||
? pattern.slice(pattern.lastIndexOf('@') + 1)
|
||||
resolvedKind === "mail" && pattern.includes("@")
|
||||
? pattern.slice(pattern.lastIndexOf("@") + 1)
|
||||
: pattern;
|
||||
if (isPublicEmailDomain(domainToCheck)) return { ok: false, error: 'public_domain' };
|
||||
if (isPublicEmailDomain(domainToCheck))
|
||||
return { ok: false, error: "public_domain" };
|
||||
const body: Record<string, string | boolean> = { pattern };
|
||||
if (kind !== undefined) body.kind = kind;
|
||||
// Land mitschicken — Backend prüft die kuratierte VIP-Liste des Landes.
|
||||
if (resolvedKind === 'web') body.country = resolveVipCountry();
|
||||
if (resolvedKind === "web") body.country = resolveVipCountry();
|
||||
if (opts?.addToVip) body.addToVip = true;
|
||||
try {
|
||||
const res = await apiFetch<any>('/api/custom-domains', {
|
||||
method: 'POST',
|
||||
const res = await apiFetch<any>("/api/custom-domains", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (res?.alreadyGlobal) return { ok: false, alreadyGlobal: true };
|
||||
if (res?.alreadyProtected) return { ok: false, alreadyProtected: true };
|
||||
if (res?.inGlobalNotVip) return { ok: false, inGlobalNotVip: true };
|
||||
await fetchDomains();
|
||||
if (res?.vipFull) return { ok: true, vipFull: true, newDomainId: res.id };
|
||||
if (res?.vipFull)
|
||||
return { ok: true, vipFull: true, newDomainId: res.id };
|
||||
return { ok: true, addedToVip: res?.addedToVip === true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'add_failed' };
|
||||
return { ok: false, error: e?.message ?? "add_failed" };
|
||||
}
|
||||
},
|
||||
[apiCount, apiLimit, fetchDomains],
|
||||
@ -241,16 +315,20 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
const submitDomain = useCallback(
|
||||
async (id: string) => {
|
||||
const tier = deriveTier(plan, domains);
|
||||
if (!tier.canSubmit) return { ok: false, error: 'plan_does_not_support_submit' };
|
||||
if (!tier.canSubmit)
|
||||
return { ok: false, error: "plan_does_not_support_submit" };
|
||||
try {
|
||||
await apiFetch(`/api/custom-domains/${id}/submit`, { method: 'POST', body: {} });
|
||||
await apiFetch(`/api/custom-domains/${id}/submit`, {
|
||||
method: "POST",
|
||||
body: {},
|
||||
});
|
||||
// Optimistisches lokales Update: Half-Donut im ProtectionDetailsSheet
|
||||
// soll sofort die neue Freigabe zeigen, ohne 60s auf Stats-Refresh zu warten.
|
||||
useBlockerStatsStore.getState().bumpMyInReview(1);
|
||||
await fetchDomains();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'submit_failed' };
|
||||
return { ok: false, error: e?.message ?? "submit_failed" };
|
||||
}
|
||||
},
|
||||
[plan, domains, fetchDomains],
|
||||
@ -259,11 +337,11 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
const removeDomain = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/custom-domains/${id}`, { method: 'DELETE' });
|
||||
await apiFetch(`/api/custom-domains/${id}`, { method: "DELETE" });
|
||||
await fetchDomains();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'remove_failed' };
|
||||
return { ok: false, error: e?.message ?? "remove_failed" };
|
||||
}
|
||||
},
|
||||
[fetchDomains],
|
||||
@ -272,14 +350,14 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
const submitVipSwap = useCallback(
|
||||
async (newDomainId: string, evictedDomainId: string) => {
|
||||
try {
|
||||
await apiFetch('/api/custom-domains/vip-swap', {
|
||||
method: 'POST',
|
||||
await apiFetch("/api/custom-domains/vip-swap", {
|
||||
method: "POST",
|
||||
body: { newDomainId, evictedDomainId },
|
||||
});
|
||||
await fetchDomains();
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'vip_swap_failed' };
|
||||
return { ok: false, error: e?.message ?? "vip_swap_failed" };
|
||||
}
|
||||
},
|
||||
[fetchDomains],
|
||||
@ -291,8 +369,9 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
|
||||
// Fallback, damit die UI auch bei einem stale-bundle-Moment funktioniert.
|
||||
const count: number =
|
||||
apiCount ??
|
||||
domains.filter((d) => d.status === 'active' || d.status === 'submitted').length;
|
||||
const limit: number = apiLimit ?? (plan === 'legend' ? 20 : 10);
|
||||
domains.filter((d) => d.status === "active" || d.status === "submitted")
|
||||
.length;
|
||||
const limit: number = apiLimit ?? (plan === "legend" ? 20 : 10);
|
||||
|
||||
return {
|
||||
domains,
|
||||
|
||||
72
apps/rebreak-native/hooks/useDmTyping.ts
Normal file
72
apps/rebreak-native/hooks/useDmTyping.ts
Normal 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 };
|
||||
}
|
||||
@ -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": "طلبات الانضمام",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>59</string>
|
||||
<string>67</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>59</string>
|
||||
<string>67</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>59</string>
|
||||
<string>67</string>
|
||||
<key>EXAppExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>EXExtensionPointIdentifier</key>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { create } from "zustand";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
export type BlockerStats = {
|
||||
current: number;
|
||||
@ -61,7 +61,7 @@ type BlockerStatsState = {
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function normalizeStats(raw: RawStatsResponse): BlockerStats {
|
||||
@ -76,12 +76,11 @@ function normalizeStats(raw: RawStatsResponse): BlockerStats {
|
||||
asNumber(raw.mySubmissions?.pending);
|
||||
|
||||
const approvedMine =
|
||||
asNumber(raw.mySubmissions?.approved) +
|
||||
asNumber(raw.mySubmissions?.active);
|
||||
asNumber(raw.mySubmissions?.approved) + asNumber(raw.mySubmissions?.active);
|
||||
|
||||
const history = Array.isArray(raw.history)
|
||||
? raw.history.map((h) => ({
|
||||
label: typeof h?.label === 'string' ? h.label : '',
|
||||
label: typeof h?.label === "string" ? h.label : "",
|
||||
count: asNumber(h?.count),
|
||||
}))
|
||||
: [];
|
||||
@ -118,14 +117,14 @@ export const useBlockerStatsStore = create<BlockerStatsState>((set, get) => ({
|
||||
inFlight = (async () => {
|
||||
set((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const raw = await apiFetch<RawStatsResponse>('/api/blocklist/stats');
|
||||
const raw = await apiFetch<RawStatsResponse>("/api/blocklist/stats");
|
||||
const stats = normalizeStats(raw ?? {});
|
||||
set({ stats, loading: false, error: null, fetchedAt: Date.now() });
|
||||
} catch (e: any) {
|
||||
set((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
error: e?.message ?? 'stats_fetch_failed',
|
||||
error: e?.message ?? "stats_fetch_failed",
|
||||
}));
|
||||
} finally {
|
||||
inFlight = null;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,10 +10,6 @@ export default defineNitroConfig({
|
||||
// Default-publicAssets greift nicht zuverlässig wenn srcDir auf "server" zeigt.
|
||||
publicAssets: [{ baseURL: "/", dir: "../public", maxAge: 60 * 60 }],
|
||||
|
||||
// Server-Assets: zur Build-Time eingebundelte Files (mobileconfig-Template etc.).
|
||||
// Lesbar via useStorage('assets:server').getItem('mdm/<file>').
|
||||
serverAssets: [{ baseName: "mdm", dir: "../ops/mdm" }],
|
||||
|
||||
// Supabase als external dep — nicht bundlen
|
||||
externals: {
|
||||
inline: [/^(?!@supabase\/supabase-js)/],
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { findMagicDeviceByToken } from "../../db/devices";
|
||||
import { MAGIC_PROFILE_TEMPLATE } from "../../utils/magic-profile-template";
|
||||
|
||||
/**
|
||||
* GET /api/magic/profile.mobileconfig?token=<dnsToken>
|
||||
*
|
||||
* Generiert personalisiertes DNS-Configuration-Profile für macOS.
|
||||
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (via Nitro serverAssets
|
||||
* unter baseName "mdm" eingebundelt — siehe nitro.config.ts).
|
||||
* Template: ops/mdm/rebreak-mac-dns-filter.mobileconfig (inlined als TS
|
||||
* constant via backend/server/utils/magic-profile-template.ts — überlebt
|
||||
* jeden Build/Deploy ohne FS- oder serverAssets-Magic).
|
||||
*
|
||||
* Ersetzt:
|
||||
* - ServerURL: /dns-query → /dns-query/{token}
|
||||
@ -36,20 +38,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Template via Nitro serverAssets lesen (build-time eingebundelt → cwd-unabhängig).
|
||||
const storage = useStorage("assets:server");
|
||||
const template = (await storage.getItem(
|
||||
"mdm/rebreak-mac-dns-filter.mobileconfig",
|
||||
)) as string | null;
|
||||
|
||||
if (!template) {
|
||||
console.error(
|
||||
"[Magic] Profile template missing in serverAssets (mdm/rebreak-mac-dns-filter.mobileconfig)",
|
||||
);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: "Profile template not found",
|
||||
});
|
||||
}
|
||||
const template = MAGIC_PROFILE_TEMPLATE;
|
||||
|
||||
// ServerURL ersetzen: /dns-query → /dns-query/{token}
|
||||
const personalizedProfile = template
|
||||
|
||||
58
backend/server/utils/magic-profile-template.ts
Normal file
58
backend/server/utils/magic-profile-template.ts
Normal 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
42
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user