diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index a417f35..47f6e30 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,21 @@ # Changelog All notable changes to rebreak-native will be documented in this file. +## v0.3.13 (Build 70 / versionCode 53) — 2026-06-03\n\n### Fixes + +- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high) +- DM screen: the keyboard now stays open after sending a message (Instagram/WhatsApp style) — it only dismisses when you tap elsewhere, instead of closing on every send +- DM info sheet: the partner avatar now renders correctly for users with a default/list avatar (not just custom photo uploads), using the same avatar component as the header. The chevron now sits inline right next to the name +- DM info sheet: tapping a shared image now opens the same full-screen viewer as in the chat (rounded corners + save button) instead of doing nothing behind the sheet + +### Features + +- DM image viewer is now a swipeable gallery — tapping any photo (in the chat or the info sheet) opens a carousel of all images shared in that conversation, starting at the one you tapped. Swipe left/right to browse, with a position counter (e.g. 2 / 6); the save button always targets the current image + +### Changes + +- DM chat background is now always the clean solid style (white in light mode, black in dark) — removed the per-chat background picker again for simplicity +- DM voice notes restyled to Instagram-style waveforms: incoming notes have black bars on a light grey bubble, your own notes have white bars on a mint-green bubble. While playing, the upcoming part dims to grey and fills back in as it progresses\n ## v0.3.13 (Build 69 / versionCode 52) — 2026-06-03\n\n### Fixes - DM screen: fixed the last message being half-hidden behind the input bar on initial open. The keyboard-closed clearance now accounts for the input bar's safe-area shift (insets.bottom), matching the comfortable gap of the keyboard-open state diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md deleted file mode 100644 index 5715db1..0000000 --- a/apps/rebreak-native/NEXT_RELEASE.md +++ /dev/null @@ -1,11 +0,0 @@ -### Fixes - -- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high) -- DM screen: the keyboard now stays open after sending a message (Instagram/WhatsApp style) — it only dismisses when you tap elsewhere, instead of closing on every send -- DM info sheet: the partner avatar now renders correctly for users with a default/list avatar (not just custom photo uploads), using the same avatar component as the header. The chevron now sits inline right next to the name -- DM info sheet: tapping a shared image now opens the same full-screen viewer as in the chat (rounded corners + save button) instead of doing nothing behind the sheet - -### Changes - -- DM chat background is now always the clean solid style (white in light mode, black in dark) — removed the per-chat background picker again for simplicity -- DM voice notes restyled to Instagram-style waveforms: incoming notes have black bars on a light grey bubble, your own notes have white bars on a mint-green bubble. While playing, the upcoming part dims to grey and fills back in as it progresses diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 87bd346..a925b65 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { supportsTablet: true, bundleIdentifier: MAIN_BUNDLE, - buildNumber: "69", + buildNumber: "70", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // com.apple.developer.applesignin-Entitlement nicht in die .entitlements. @@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 52, + versionCode: 53, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 742faab..e85a620 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -24,7 +24,12 @@ import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import * as ImagePicker from 'expo-image-picker'; -import * as MediaLibrary from 'expo-media-library'; +import { requireOptionalNativeModule } from 'expo-modules-core'; +// expo-media-library wird LAZY in saveImage() geladen (require statt top-level +// import): ist das native Modul in einem älteren Build nicht eincompiliert, +// würde ein top-level import den GANZEN DM-Screen crashen. Lazy → nur das +// Speichern schlägt fehl, der Screen lädt normal. +type MediaLibraryModule = typeof import('expo-media-library'); // TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 import * as FileSystem from 'expo-file-system/legacy'; import { apiFetch } from '../lib/api'; @@ -118,6 +123,10 @@ export default function DmScreen() { const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( null, ); + // Ref auf das Text-Eingabefeld → nach dem Senden Fokus re-asserten, damit die + // Tastatur offen bleibt (Insta/WA-Style), auch wenn das Leeren des Inputs den + // Send-Button gegen den Mic-Button austauscht. + const inputRef = useRef(null); const [sending, setSending] = useState(false); const [inputText, setInputText] = useState(''); const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null); @@ -126,20 +135,34 @@ export default function DmScreen() { const [keyboardHeight, setKeyboardHeight] = useState(0); const [inputBarHeight, setInputBarHeight] = useState(60); const [infoSheetOpen, setInfoSheetOpen] = useState(false); - const [lightboxUri, setLightboxUri] = useState(null); - // Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um - // den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die - // sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats. - const [lightboxRatio, setLightboxRatio] = useState(null); + // Lightbox = Carousel über ALLE geteilten Bilder der Konversation. Tippt man + // ein Bild an, öffnet die Galerie bei dessen Index und man kann horizontal + // zwischen allen Bildern wischen. + const [lightboxImages, setLightboxImages] = useState([]); + const [lightboxIndex, setLightboxIndex] = useState(0); + // Index → echtes Seitenverhältnis (via onLoad), damit der Container exakt auf + // die Bildmaße passt und borderRadius die sichtbaren Foto-Ecken rundet. + const [lightboxRatios, setLightboxRatios] = useState>({}); const [savingImage, setSavingImage] = useState(false); + const lightboxOpen = lightboxImages.length > 0; + + // messagesRef, damit openLightbox (useCallback []) immer die aktuelle Bildliste + // sieht, ohne bei jeder neuen Nachricht neu erzeugt zu werden. + const messagesRef = useRef(messages); + messagesRef.current = messages; const openLightbox = useCallback((uri: string) => { - setLightboxRatio(null); - setLightboxUri(uri); + const urls = messagesRef.current + .filter((m) => m.attachmentType === 'image' && m.attachmentUrl) + .map((m) => m.attachmentUrl as string); + const list = urls.length ? urls : [uri]; + setLightboxImages(list); + setLightboxIndex(Math.max(0, list.indexOf(uri))); + setLightboxRatios({}); }, []); const closeLightbox = useCallback(() => { - setLightboxUri(null); - setLightboxRatio(null); + setLightboxImages([]); + setLightboxRatios({}); }, []); // Voice recording @@ -381,6 +404,9 @@ export default function DmScreen() { setReplyTo(null); setSending(true); sendStopTyping(); + // Fokus halten: das Leeren des Inputs tauscht Send→Mic-Button und kann den + // Fokus verlieren. Re-assert nach dem Re-Render → Tastatur bleibt offen. + requestAnimationFrame(() => inputRef.current?.focus()); try { let attachmentMeta: { url: string; type: string; name: string } | null = null; @@ -660,6 +686,16 @@ export default function DmScreen() { // lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht. async function saveImage(uri: string) { if (savingImage) return; + // In einem älteren Build ohne eincompiliertes expo-media-library existieren + // die JS-Wrapper zwar, der native Teil aber nicht → der Call würde tief drin + // mit "Cannot read property ... of undefined" failen. requireOptionalNative- + // Module gibt null zurück (statt zu werfen), wenn das native Modul fehlt → + // saubere Vorab-Prüfung mit klarer Meldung. + if (!requireOptionalNativeModule('ExpoMediaLibrary')) { + Alert.alert(t('chat.save_failed'), t('chat.save_needs_rebuild')); + return; + } + const MediaLibrary: MediaLibraryModule = require('expo-media-library'); try { setSavingImage(true); const perm = await MediaLibrary.requestPermissionsAsync(); @@ -693,15 +729,18 @@ export default function DmScreen() { 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; + function lbFitDims(ratio?: number) { + let w = lbMaxW; + let h = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat + if (ratio) { + w = lbMaxW; + h = lbMaxW / ratio; + if (h > lbMaxH) { + h = lbMaxH; + w = lbMaxH * ratio; + } } + return { width: w, height: h }; } return ( @@ -862,6 +901,7 @@ export default function DmScreen() { - {/* ── Lightbox ───────────────────────────────────────────────── */} - - - {lightboxUri && ( - { - const s = e.source; - if (s?.width && s?.height) setLightboxRatio(s.width / s.height); + {/* ── Lightbox-Carousel ──────────────────────────────────────── */} + + + ({ length: lbWin.width, offset: lbWin.width * i, index: i })} + keyExtractor={(u, i) => `${i}-${u}`} + onMomentumScrollEnd={(e) => + setLightboxIndex(Math.round(e.nativeEvent.contentOffset.x / lbWin.width)) + } + renderItem={({ item, index }) => ( + + { + const s = e.source; + if (s?.width && s?.height) + setLightboxRatios((r) => ({ ...r, [index]: s.width / s.height })); + }} + style={{ ...lbFitDims(lightboxRatios[index]), borderRadius: 16 }} + contentFit="contain" + cachePolicy="memory-disk" + /> + + )} + /> + + {/* Zähler "2 / 6" — nur bei mehreren Bildern */} + {lightboxImages.length > 1 && ( + + pointerEvents="none" + > + + {lightboxIndex + 1} / {lightboxImages.length} + + )} + - {/* Sichern */} + + {/* Sichern (aktuelles Bild) */} lightboxUri && saveImage(lightboxUri)} + onPress={() => lightboxImages[lightboxIndex] && saveImage(lightboxImages[lightboxIndex])} disabled={savingImage} activeOpacity={0.7} > @@ -982,7 +1059,7 @@ export default function DmScreen() { {t('chat.save')} - + ); diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx index 801d609..1b15992 100644 --- a/apps/rebreak-native/app/profile/index.tsx +++ b/apps/rebreak-native/app/profile/index.tsx @@ -134,6 +134,22 @@ export default function ProfileScreen() { } } + // Voice-Calls Opt-out (nur zwischen gegenseitigen Follows). Default an. + const [callsEnabled, setCallsEnabled] = useState(true); + useEffect(() => { + if (me?.callsEnabled !== undefined) setCallsEnabled(me.callsEnabled); + }, [me?.callsEnabled]); + + async function toggleCalls() { + const next = !callsEnabled; + setCallsEnabled(next); + try { + await apiFetch('/api/me/calls-enabled', { method: 'POST', body: { enabled: next } }); + } catch { + setCallsEnabled(!next); // Rollback bei Fehler + } + } + const { stats: socialStats } = useSocialStats(me?.id); const { domains: approvedDomainsData } = useApprovedDomains(); const { cooldownHistory } = useCooldownHistory(); @@ -346,6 +362,30 @@ export default function ProfileScreen() { + + + + + {t('profile.allow_calls')} + + + {t('profile.allow_calls_hint')} + + + + diff --git a/apps/rebreak-native/components/chat/ChatBubble.tsx b/apps/rebreak-native/components/chat/ChatBubble.tsx index 70851c5..a7f771a 100644 --- a/apps/rebreak-native/components/chat/ChatBubble.tsx +++ b/apps/rebreak-native/components/chat/ChatBubble.tsx @@ -30,7 +30,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); const [currentTime, setCurrentTime] = useState(0); - const [waveWidth, setWaveWidth] = useState(0); const soundRef = useRef(null); const pollRef = useRef | null>(null); // Merkt sich ob die Wiedergabe komplett durchlief — dann muss der nächste @@ -114,8 +113,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri } const playedCount = Math.floor(progress * barHeights.length); - const DOT_SIZE = 7; - const dotLeft = waveWidth > 0 ? Math.max(0, progress * waveWidth - DOT_SIZE / 2) : 0; // Insta-Style Wellenform-Farben: // - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte @@ -130,7 +127,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri const showFullBars = !isPlaying && progress === 0; const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)'; const playIconColor = isOwn ? '#ffffff' : colors.text; - const dotColor = '#007AFF'; const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : colors.textMuted; const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds)); @@ -145,10 +141,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri - setWaveWidth(e.nativeEvent.layout.width)} - > + {barHeights.map((h, i) => ( ))} - - {waveWidth > 0 && ( - - )} diff --git a/apps/rebreak-native/hooks/useMe.ts b/apps/rebreak-native/hooks/useMe.ts index 8f5f630..fcb7420 100644 --- a/apps/rebreak-native/hooks/useMe.ts +++ b/apps/rebreak-native/hooks/useMe.ts @@ -39,6 +39,7 @@ export type Me = { onboardingStep: OnboardingStep; created_at?: string; presenceVisible?: boolean; + callsEnabled?: boolean; }; let cachedMe: Me | null = null; diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index 3bb8a3d..cfa2e22 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -1036,6 +1036,7 @@ "save": "حفظ", "image_saved": "تم حفظ الصورة في الصور", "save_failed": "تعذّر حفظ الصورة", + "save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.", "member_count": "%{n} أعضاء", "member_count_online": "%{n} أعضاء · %{online} متصل", "pending_request": "طلبات الانضمام", @@ -1149,7 +1150,9 @@ }, "privacy_section_title": "الخصوصية", "show_online_status": "إظهار حالة الاتصال", - "show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً" + "show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً", + "allow_calls": "السماح بالمكالمات", + "allow_calls_hint": "فقط الأشخاص الذين تتابعهم بالمقابل يمكنهم الاتصال بك. إيقاف = لا مكالمات" }, "demographics": { "employment_status_employed": "موظف", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index cc7c08e..a415d14 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1107,6 +1107,7 @@ "save": "Sichern", "image_saved": "Bild in Fotos gesichert", "save_failed": "Bild konnte nicht gesichert werden", + "save_needs_rebuild": "Speichern braucht ein App-Update — bitte nach dem nächsten Build erneut versuchen.", "member_count": "%{n} Mitglieder", "member_count_online": "%{n} Mitglieder · %{online} online", "pending_request": "Beitrittsanfragen", @@ -1221,7 +1222,9 @@ }, "privacy_section_title": "Privatsphäre", "show_online_status": "Online-Status anzeigen", - "show_online_status_hint": "Nur Personen, denen du folgst, sehen wenn du online bist" + "show_online_status_hint": "Nur Personen, denen du folgst, sehen wenn du online bist", + "allow_calls": "Anrufe erlauben", + "allow_calls_hint": "Nur Personen, denen du zurückfolgst, können dich anrufen. Aus = keine Anrufe" }, "demographics": { "employment_status_employed": "angestellt", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 48e259c..8b561c2 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1105,6 +1105,7 @@ "save": "Save", "image_saved": "Image saved to Photos", "save_failed": "Could not save image", + "save_needs_rebuild": "Saving needs an app update — please try again after the next build.", "member_count": "%{n} members", "member_count_online": "%{n} members · %{online} online", "pending_request": "Join requests", @@ -1219,7 +1220,9 @@ }, "privacy_section_title": "Privacy", "show_online_status": "Show online status", - "show_online_status_hint": "Only people you follow see when you're online" + "show_online_status_hint": "Only people you follow see when you're online", + "allow_calls": "Allow calls", + "allow_calls_hint": "Only people you follow back can call you. Turn off to receive no calls" }, "demographics": { "employment_status_employed": "employed", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 7c885e9..27b56ff 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -1025,6 +1025,7 @@ "save": "Enregistrer", "image_saved": "Image enregistrée dans Photos", "save_failed": "Impossible d'enregistrer l'image", + "save_needs_rebuild": "L'enregistrement nécessite une mise à jour de l'app — réessaie après la prochaine build.", "member_count": "%{n} membres", "member_count_online": "%{n} membres · %{online} en ligne", "pending_request": "Demandes d'adhésion", @@ -1138,7 +1139,9 @@ }, "privacy_section_title": "Confidentialité", "show_online_status": "Afficher le statut en ligne", - "show_online_status_hint": "Seules les personnes que vous suivez voient si vous êtes en ligne" + "show_online_status_hint": "Seules les personnes que vous suivez voient si vous êtes en ligne", + "allow_calls": "Autoriser les appels", + "allow_calls_hint": "Seules les personnes que vous suivez en retour peuvent vous appeler. Désactivé = aucun appel" }, "demographics": { "employment_status_employed": "salarié", diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist index 9a8544b..ca2b9a1 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 69 + 70 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist index 128c622..9828da0 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 69 + 70 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index 8e795be..f4b8478 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 69 + 70 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index bccff36..45ebfff 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -40,6 +40,7 @@ "expo-image": "~3.0.11", "expo-image-manipulator": "~14.0.7", "expo-image-picker": "~17.0.11", + "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.12", "expo-local-authentication": "~17.0.8", "expo-localization": "~17.0.8", diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index 1ce4e37..ed281b6 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -41,9 +41,12 @@ Building Release AAB (gradlew bundleRelease)|326 Validating IPA (App-Store Connect)|86 Uploading zu App-Store Connect (TestFlight)|112 Building Release AAB (gradlew bundleRelease)|272 -Building xcarchive|198 -Exporting Ad-Hoc IPA|18 -Exporting App-Store IPA|23 Validating IPA (App-Store Connect)|117 Uploading zu App-Store Connect (TestFlight)|138 Building Release AAB (gradlew bundleRelease)|273 +Building xcarchive|213 +Exporting Ad-Hoc IPA|18 +Exporting App-Store IPA|23 +Validating IPA (App-Store Connect)|78 +Uploading zu App-Store Connect (TestFlight)|90 +Building Release AAB (gradlew bundleRelease)|321 diff --git a/backend/prisma/migrations/20260603_add_calls_enabled/migration.sql b/backend/prisma/migrations/20260603_add_calls_enabled/migration.sql new file mode 100644 index 0000000..75671cf --- /dev/null +++ b/backend/prisma/migrations/20260603_add_calls_enabled/migration.sql @@ -0,0 +1,8 @@ +-- Voice-Calls Opt-out-Flag pro User (Phase 0). +-- Default true: Anrufe sind erlaubt, solange beide sich gegenseitig folgen. +-- Server-seitig erzwungen in social.canCall (mutual-follow + calls_enabled). +-- +-- Deploy: pnpm prisma migrate deploy (auf Hetzner, via deploy-from-artifact.sh) + +ALTER TABLE "rebreak"."profiles" + ADD COLUMN IF NOT EXISTS "calls_enabled" boolean NOT NULL DEFAULT true; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 33fde05..dcf340f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -85,6 +85,11 @@ model Profile { lastSeenAt DateTime? @map("last_seen_at") presenceVisible Boolean @default(true) @map("presence_visible") + // ─── Voice-Calls (DM, nur zwischen gegenseitigen Follows) ─────────────── + // Opt-out: User kann eingehende Anrufe komplett abschalten. Server-seitig + // erzwungen in social.canCall (mutual-follow + callsEnabled). Default an. + callsEnabled Boolean @default(true) @map("calls_enabled") + // ─── Voice-Quota (tages-basiert, UTC-Reset) ───────────────────────────── // Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking). // voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt. diff --git a/backend/server/api/auth/me.get.ts b/backend/server/api/auth/me.get.ts index f0fa80d..e100250 100644 --- a/backend/server/api/auth/me.get.ts +++ b/backend/server/api/auth/me.get.ts @@ -39,5 +39,6 @@ export default defineEventHandler(async (event) => { globalBlocklistGraceUntil: dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null, presenceVisible: dbProfile?.presenceVisible ?? true, + callsEnabled: dbProfile?.callsEnabled ?? true, }; }); diff --git a/backend/server/api/chat/can-call/[userId].get.ts b/backend/server/api/chat/can-call/[userId].get.ts new file mode 100644 index 0000000..31d1933 --- /dev/null +++ b/backend/server/api/chat/can-call/[userId].get.ts @@ -0,0 +1,20 @@ +/** + * GET /api/chat/can-call/:userId + * + * Darf der eingeloggte User den :userId anrufen? true nur bei gegenseitigem + * Follow UND wenn der Angerufene Anrufe nicht deaktiviert hat. Nutzt die + * Frontend-UI, um den Call-Button im DM-Header ein-/auszublenden. + * + * Response: { canCall: boolean } + */ +import { requireUser } from "../../../utils/auth"; +import { canCall } from "../../../db/social"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + const userId = getRouterParam(event, "userId"); + if (!userId) { + throw createError({ statusCode: 400, message: "MISSING_USER_ID" }); + } + return { canCall: await canCall(user.id, userId) }; +}); diff --git a/backend/server/api/me/calls-enabled.post.ts b/backend/server/api/me/calls-enabled.post.ts new file mode 100644 index 0000000..2deeef2 --- /dev/null +++ b/backend/server/api/me/calls-enabled.post.ts @@ -0,0 +1,23 @@ +/** + * POST /api/me/calls-enabled + * + * Opt-out toggle für eingehende Voice-Calls des authentifizierten Users. + * calls_enabled=false → andere User können nicht anrufen (zusätzlich zur + * Mutual-Follow-Schranke). Default true. + * + * Body: { enabled: boolean } + * Response: { callsEnabled: boolean } + */ +import { requireUser } from "../../utils/auth"; +import { setCallsEnabled } from "../../db/profile"; + +export default defineEventHandler(async (event) => { + const user = await requireUser(event); + + const body = await readBody(event); + if (typeof body?.enabled !== "boolean") { + throw createError({ statusCode: 400, message: "INVALID_ENABLED" }); + } + + return setCallsEnabled(user.id, body.enabled); +}); diff --git a/backend/server/db/profile.ts b/backend/server/db/profile.ts index a981fb7..8b4be16 100644 --- a/backend/server/db/profile.ts +++ b/backend/server/db/profile.ts @@ -391,6 +391,19 @@ export async function setMdmManaged( } /** Update presence_visible opt-out toggle for a user. */ +export async function setCallsEnabled( + userId: string, + enabled: boolean, +): Promise<{ callsEnabled: boolean }> { + const db = usePrisma(); + await db.profile.update({ + where: { id: userId }, + data: { callsEnabled: enabled }, + select: { callsEnabled: true }, + }); + return { callsEnabled: enabled }; +} + export async function setPresenceVisible( userId: string, visible: boolean, diff --git a/backend/server/db/social.ts b/backend/server/db/social.ts index 1c5b09c..dd6dffa 100644 --- a/backend/server/db/social.ts +++ b/backend/server/db/social.ts @@ -71,3 +71,34 @@ export async function getProfileWithFollowers(userId: string) { }, }); } + +/** + * Voice-Call erlaubt? Nur wenn (1) beide sich GEGENSEITIG folgen UND (2) der + * Angerufene Anrufe nicht deaktiviert hat (callsEnabled). Server-seitige + * Hard-Schranke — die UI blendet den Call-Button zwar aus, aber der Call-Start + * MUSS das hier zusätzlich prüfen. + */ +export async function canCall( + callerId: string, + calleeId: string, +): Promise { + if (!callerId || !calleeId || callerId === calleeId) return false; + const db = usePrisma(); + const [callerFollowsCallee, calleeFollowsCaller, callee] = await Promise.all([ + db.userFollow.findUnique({ + where: { followerId_followingId: { followerId: callerId, followingId: calleeId } }, + select: { followerId: true }, + }), + db.userFollow.findUnique({ + where: { followerId_followingId: { followerId: calleeId, followingId: callerId } }, + select: { followerId: true }, + }), + db.profile.findUnique({ + where: { id: calleeId }, + select: { callsEnabled: true }, + }), + ]); + return ( + !!callerFollowsCallee && !!calleeFollowsCaller && (callee?.callsEnabled ?? true) + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ddaaf6..2d3ee3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: expo-image-picker: specifier: ~17.0.11 version: 17.0.11(expo@54.0.34) + expo-linear-gradient: + specifier: ~15.0.8 + version: 15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) expo-linking: specifier: ~8.0.12 version: 8.0.12(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) @@ -5603,6 +5606,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@15.0.8: + resolution: {integrity: sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@8.0.12: resolution: {integrity: sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==} peerDependencies: @@ -15704,6 +15714,12 @@ snapshots: 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: 19.1.0 + expo-linear-gradient@15.0.8(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(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: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + expo-linking@8.0.12(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: expo-constants: 18.0.13(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))