feat(calls): Phase 0 — calls_enabled opt-out + canCall guard (mutual-follow); DM UI batch

Backend (voice-call groundwork, no call engine yet):
- Profile.callsEnabled (Boolean default true) + migration
- canCall(caller,callee): mutual-follow AND callee.callsEnabled — server-side hard guard
- POST /api/me/calls-enabled (opt-out toggle), GET /api/chat/can-call/:userId
- expose callsEnabled in /api/auth/me

Frontend:
- "Allow calls" toggle in Profile privacy section (default on, optimistic+rollback)
- Me.callsEnabled + i18n DE/EN/FR/AR

Bundled DM UI work from this session:
- image lightbox is now a swipeable carousel over all shared images (+ counter)
- keyboard stays open after sending (input ref refocus)
- voice notes: Instagram-style waveforms (own=white/mint, other=black/grey),
  removed the blue progress dot; lazy-load expo-media-library with clean fallback
- expo-linear-gradient + expo-media-library deps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-03 21:14:31 +02:00
parent 50425a62ee
commit 89e4e3481b
24 changed files with 318 additions and 83 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<TextInput>(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<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);
// 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<string[]>([]);
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<Record<number, number>>({});
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() {
<Ionicons name="add" size={22} color={colors.textMuted} />
</TouchableOpacity>
<TextInput
ref={inputRef}
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
placeholder={t('chat.placeholder')}
placeholderTextColor={colors.textMuted}
@ -929,33 +969,70 @@ export default function DmScreen() {
t={t}
/>
{/* ── Lightbox ───────────────────────────────────────────────── */}
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
<TouchableOpacity
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
activeOpacity={1}
onPress={closeLightbox}
>
{lightboxUri && (
<Image
source={{ uri: lightboxUri }}
onLoad={(e) => {
const s = e.source;
if (s?.width && s?.height) setLightboxRatio(s.width / s.height);
{/* ── Lightbox-Carousel ──────────────────────────────────────── */}
<Modal visible={lightboxOpen} transparent animationType="fade" onRequestClose={closeLightbox}>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)' }}>
<FlatList
data={lightboxImages}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={lightboxIndex}
getItemLayout={(_, i) => ({ 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 }) => (
<TouchableOpacity
activeOpacity={1}
onPress={closeLightbox}
style={{ width: lbWin.width, height: lbWin.height, alignItems: 'center', justifyContent: 'center' }}
>
<Image
source={{ uri: item }}
onLoad={(e) => {
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"
/>
</TouchableOpacity>
)}
/>
{/* Zähler "2 / 6" — nur bei mehreren Bildern */}
{lightboxImages.length > 1 && (
<View
style={{
position: 'absolute',
top: 56,
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
style={{ width: lbW, height: lbH, borderRadius: 16 }}
contentFit="contain"
cachePolicy="memory-disk"
/>
pointerEvents="none"
>
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Nunito_700Bold', fontVariant: ['tabular-nums'] }}>
{lightboxIndex + 1} / {lightboxImages.length}
</Text>
</View>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
style={{ position: 'absolute', top: 50, right: 20, padding: 8 }}
onPress={closeLightbox}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
{/* Sichern */}
{/* Sichern (aktuelles Bild) */}
<TouchableOpacity
style={{
position: 'absolute',
@ -969,7 +1046,7 @@ export default function DmScreen() {
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.16)',
}}
onPress={() => 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')}
</Text>
</TouchableOpacity>
</TouchableOpacity>
</View>
</Modal>
</SafeAreaView>
);

View File

@ -134,6 +134,22 @@ export default function ProfileScreen() {
}
}
// Voice-Calls Opt-out (nur zwischen gegenseitigen Follows). Default an.
const [callsEnabled, setCallsEnabled] = useState<boolean>(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() {
</View>
<Switch value={presenceVisible} onValueChange={togglePresence} />
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.card,
padding: 14,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
marginTop: 10,
}}
>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('profile.allow_calls')}
</Text>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('profile.allow_calls_hint')}
</Text>
</View>
<Switch value={callsEnabled} onValueChange={toggleCalls} />
</View>
</View>
<View style={{ height: 24 }} />

View File

@ -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<Audio.Sound | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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
</View>
</TouchableOpacity>
<View
style={{ flex: 1, height: 26, position: 'relative' }}
onLayout={(e) => setWaveWidth(e.nativeEvent.layout.width)}
>
<View style={{ flex: 1, height: 26, position: 'relative' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
{barHeights.map((h, i) => (
<View
@ -157,19 +150,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
/>
))}
</View>
{waveWidth > 0 && (
<View style={{
position: 'absolute',
top: '50%',
marginTop: -(DOT_SIZE / 2),
left: dotLeft,
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: DOT_SIZE / 2,
backgroundColor: dotColor,
}} />
)}
</View>
</View>

View File

@ -39,6 +39,7 @@ export type Me = {
onboardingStep: OnboardingStep;
created_at?: string;
presenceVisible?: boolean;
callsEnabled?: boolean;
};
let cachedMe: Me | null = null;

View File

@ -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": "موظف",

View File

@ -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",

View File

@ -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",

View File

@ -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é",

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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

View File

@ -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;

View File

@ -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.

View File

@ -39,5 +39,6 @@ export default defineEventHandler(async (event) => {
globalBlocklistGraceUntil:
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
presenceVisible: dbProfile?.presenceVisible ?? true,
callsEnabled: dbProfile?.callsEnabled ?? true,
};
});

View File

@ -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) };
});

View File

@ -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);
});

View File

@ -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,

View File

@ -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<boolean> {
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)
);
}

16
pnpm-lock.yaml generated
View File

@ -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))