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
|
// MARK: - Logic
|
||||||
|
|
||||||
private func handleDigitInput(_ raw: String, at index: Int) {
|
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 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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: {
|
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",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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": "صورة",
|
"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": "طلبات الانضمام",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)/],
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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))
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user