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:
parent
50425a62ee
commit
89e4e3481b
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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,16 +729,19 @@ 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 (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
@ -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}>
|
||||
{/* ── 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
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
|
||||
activeOpacity={1}
|
||||
onPress={closeLightbox}
|
||||
style={{ width: lbWin.width, height: lbWin.height, alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{lightboxUri && (
|
||||
<Image
|
||||
source={{ uri: lightboxUri }}
|
||||
source={{ uri: item }}
|
||||
onLoad={(e) => {
|
||||
const s = e.source;
|
||||
if (s?.width && s?.height) setLightboxRatio(s.width / s.height);
|
||||
if (s?.width && s?.height)
|
||||
setLightboxRatios((r) => ({ ...r, [index]: s.width / s.height }));
|
||||
}}
|
||||
style={{ width: lbW, height: lbH, borderRadius: 16 }}
|
||||
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)',
|
||||
}}
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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 }} />
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ export type Me = {
|
||||
onboardingStep: OnboardingStep;
|
||||
created_at?: string;
|
||||
presenceVisible?: boolean;
|
||||
callsEnabled?: boolean;
|
||||
};
|
||||
|
||||
let cachedMe: Me | null = null;
|
||||
|
||||
@ -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": "موظف",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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é",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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.
|
||||
|
||||
@ -39,5 +39,6 @@ export default defineEventHandler(async (event) => {
|
||||
globalBlocklistGraceUntil:
|
||||
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
||||
presenceVisible: dbProfile?.presenceVisible ?? true,
|
||||
callsEnabled: dbProfile?.callsEnabled ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
20
backend/server/api/chat/can-call/[userId].get.ts
Normal file
20
backend/server/api/chat/can-call/[userId].get.ts
Normal 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) };
|
||||
});
|
||||
23
backend/server/api/me/calls-enabled.post.ts
Normal file
23
backend/server/api/me/calls-enabled.post.ts
Normal 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);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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
16
pnpm-lock.yaml
generated
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user