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
|
# 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 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
|
## 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
|
- 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: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: MAIN_BUNDLE,
|
bundleIdentifier: MAIN_BUNDLE,
|
||||||
buildNumber: "69",
|
buildNumber: "70",
|
||||||
// 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: 52,
|
versionCode: 53,
|
||||||
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
|
||||||
|
|||||||
@ -24,7 +24,12 @@ 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';
|
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
|
// 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';
|
||||||
@ -118,6 +123,10 @@ export default function DmScreen() {
|
|||||||
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
||||||
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 [sending, setSending] = useState(false);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null);
|
const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null);
|
||||||
@ -126,20 +135,34 @@ export default function DmScreen() {
|
|||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
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);
|
// Lightbox = Carousel über ALLE geteilten Bilder der Konversation. Tippt man
|
||||||
// Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um
|
// ein Bild an, öffnet die Galerie bei dessen Index und man kann horizontal
|
||||||
// den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die
|
// zwischen allen Bildern wischen.
|
||||||
// sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats.
|
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||||||
const [lightboxRatio, setLightboxRatio] = useState<number | null>(null);
|
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 [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) => {
|
const openLightbox = useCallback((uri: string) => {
|
||||||
setLightboxRatio(null);
|
const urls = messagesRef.current
|
||||||
setLightboxUri(uri);
|
.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(() => {
|
const closeLightbox = useCallback(() => {
|
||||||
setLightboxUri(null);
|
setLightboxImages([]);
|
||||||
setLightboxRatio(null);
|
setLightboxRatios({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Voice recording
|
// Voice recording
|
||||||
@ -381,6 +404,9 @@ export default function DmScreen() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
sendStopTyping();
|
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 {
|
try {
|
||||||
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
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.
|
// lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht.
|
||||||
async function saveImage(uri: string) {
|
async function saveImage(uri: string) {
|
||||||
if (savingImage) return;
|
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 {
|
try {
|
||||||
setSavingImage(true);
|
setSavingImage(true);
|
||||||
const perm = await MediaLibrary.requestPermissionsAsync();
|
const perm = await MediaLibrary.requestPermissionsAsync();
|
||||||
@ -693,16 +729,19 @@ export default function DmScreen() {
|
|||||||
const lbWin = Dimensions.get('window');
|
const lbWin = Dimensions.get('window');
|
||||||
const lbMaxW = lbWin.width - 24;
|
const lbMaxW = lbWin.width - 24;
|
||||||
const lbMaxH = lbWin.height * 0.78;
|
const lbMaxH = lbWin.height * 0.78;
|
||||||
let lbW = lbMaxW;
|
function lbFitDims(ratio?: number) {
|
||||||
let lbH = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
|
let w = lbMaxW;
|
||||||
if (lightboxRatio) {
|
let h = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
|
||||||
lbW = lbMaxW;
|
if (ratio) {
|
||||||
lbH = lbMaxW / lightboxRatio;
|
w = lbMaxW;
|
||||||
if (lbH > lbMaxH) {
|
h = lbMaxW / ratio;
|
||||||
lbH = lbMaxH;
|
if (h > lbMaxH) {
|
||||||
lbW = lbMaxH * lightboxRatio;
|
h = lbMaxH;
|
||||||
|
w = lbMaxH * ratio;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
@ -862,6 +901,7 @@ export default function DmScreen() {
|
|||||||
<Ionicons name="add" size={22} color={colors.textMuted} />
|
<Ionicons name="add" size={22} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
|
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
|
||||||
placeholder={t('chat.placeholder')}
|
placeholder={t('chat.placeholder')}
|
||||||
placeholderTextColor={colors.textMuted}
|
placeholderTextColor={colors.textMuted}
|
||||||
@ -929,33 +969,70 @@ export default function DmScreen() {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Lightbox ───────────────────────────────────────────────── */}
|
{/* ── Lightbox-Carousel ──────────────────────────────────────── */}
|
||||||
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
|
<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
|
<TouchableOpacity
|
||||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
activeOpacity={1}
|
activeOpacity={1}
|
||||||
onPress={closeLightbox}
|
onPress={closeLightbox}
|
||||||
|
style={{ width: lbWin.width, height: lbWin.height, alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>
|
||||||
{lightboxUri && (
|
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: lightboxUri }}
|
source={{ uri: item }}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const s = e.source;
|
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"
|
contentFit="contain"
|
||||||
cachePolicy="memory-disk"
|
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
|
<TouchableOpacity
|
||||||
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
|
style={{ position: 'absolute', top: 50, right: 20, padding: 8 }}
|
||||||
onPress={closeLightbox}
|
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 */}
|
|
||||||
|
{/* Sichern (aktuelles Bild) */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -969,7 +1046,7 @@ export default function DmScreen() {
|
|||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
backgroundColor: 'rgba(255,255,255,0.16)',
|
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||||
}}
|
}}
|
||||||
onPress={() => lightboxUri && saveImage(lightboxUri)}
|
onPress={() => lightboxImages[lightboxIndex] && saveImage(lightboxImages[lightboxIndex])}
|
||||||
disabled={savingImage}
|
disabled={savingImage}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
@ -982,7 +1059,7 @@ export default function DmScreen() {
|
|||||||
{t('chat.save')}
|
{t('chat.save')}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
</SafeAreaView>
|
</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 { stats: socialStats } = useSocialStats(me?.id);
|
||||||
const { domains: approvedDomainsData } = useApprovedDomains();
|
const { domains: approvedDomainsData } = useApprovedDomains();
|
||||||
const { cooldownHistory } = useCooldownHistory();
|
const { cooldownHistory } = useCooldownHistory();
|
||||||
@ -346,6 +362,30 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<Switch value={presenceVisible} onValueChange={togglePresence} />
|
<Switch value={presenceVisible} onValueChange={togglePresence} />
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 24 }} />
|
||||||
|
|||||||
@ -30,7 +30,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = 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
|
// 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 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:
|
// Insta-Style Wellenform-Farben:
|
||||||
// - eigene Bubble (Mint-BG): Inhalt weiß, gespielte Bars weiß, ungespielte
|
// - 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 showFullBars = !isPlaying && progress === 0;
|
||||||
const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)';
|
const playBtnBg = isOwn ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.06)';
|
||||||
const playIconColor = isOwn ? '#ffffff' : colors.text;
|
const playIconColor = isOwn ? '#ffffff' : colors.text;
|
||||||
const dotColor = '#007AFF';
|
|
||||||
const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : colors.textMuted;
|
const durationColor = isOwn ? 'rgba(255,255,255,0.9)' : colors.textMuted;
|
||||||
const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
|
const displayDuration = isPlaying ? fmtSec(currentTime) : (duration || fmtSec(totalSeconds));
|
||||||
|
|
||||||
@ -145,10 +141,7 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View
|
<View style={{ flex: 1, height: 26, position: 'relative' }}>
|
||||||
style={{ flex: 1, height: 26, position: 'relative' }}
|
|
||||||
onLayout={(e) => setWaveWidth(e.nativeEvent.layout.width)}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
|
||||||
{barHeights.map((h, i) => (
|
{barHeights.map((h, i) => (
|
||||||
<View
|
<View
|
||||||
@ -157,19 +150,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export type Me = {
|
|||||||
onboardingStep: OnboardingStep;
|
onboardingStep: OnboardingStep;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
presenceVisible?: boolean;
|
presenceVisible?: boolean;
|
||||||
|
callsEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedMe: Me | null = null;
|
let cachedMe: Me | null = null;
|
||||||
|
|||||||
@ -1036,6 +1036,7 @@
|
|||||||
"save": "حفظ",
|
"save": "حفظ",
|
||||||
"image_saved": "تم حفظ الصورة في الصور",
|
"image_saved": "تم حفظ الصورة في الصور",
|
||||||
"save_failed": "تعذّر حفظ الصورة",
|
"save_failed": "تعذّر حفظ الصورة",
|
||||||
|
"save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.",
|
||||||
"member_count": "%{n} أعضاء",
|
"member_count": "%{n} أعضاء",
|
||||||
"member_count_online": "%{n} أعضاء · %{online} متصل",
|
"member_count_online": "%{n} أعضاء · %{online} متصل",
|
||||||
"pending_request": "طلبات الانضمام",
|
"pending_request": "طلبات الانضمام",
|
||||||
@ -1149,7 +1150,9 @@
|
|||||||
},
|
},
|
||||||
"privacy_section_title": "الخصوصية",
|
"privacy_section_title": "الخصوصية",
|
||||||
"show_online_status": "إظهار حالة الاتصال",
|
"show_online_status": "إظهار حالة الاتصال",
|
||||||
"show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً"
|
"show_online_status_hint": "فقط الأشخاص الذين تتابعهم يرون متى تكون متصلاً",
|
||||||
|
"allow_calls": "السماح بالمكالمات",
|
||||||
|
"allow_calls_hint": "فقط الأشخاص الذين تتابعهم بالمقابل يمكنهم الاتصال بك. إيقاف = لا مكالمات"
|
||||||
},
|
},
|
||||||
"demographics": {
|
"demographics": {
|
||||||
"employment_status_employed": "موظف",
|
"employment_status_employed": "موظف",
|
||||||
|
|||||||
@ -1107,6 +1107,7 @@
|
|||||||
"save": "Sichern",
|
"save": "Sichern",
|
||||||
"image_saved": "Bild in Fotos gesichert",
|
"image_saved": "Bild in Fotos gesichert",
|
||||||
"save_failed": "Bild konnte nicht gesichert werden",
|
"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": "%{n} Mitglieder",
|
||||||
"member_count_online": "%{n} Mitglieder · %{online} online",
|
"member_count_online": "%{n} Mitglieder · %{online} online",
|
||||||
"pending_request": "Beitrittsanfragen",
|
"pending_request": "Beitrittsanfragen",
|
||||||
@ -1221,7 +1222,9 @@
|
|||||||
},
|
},
|
||||||
"privacy_section_title": "Privatsphäre",
|
"privacy_section_title": "Privatsphäre",
|
||||||
"show_online_status": "Online-Status anzeigen",
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "angestellt",
|
"employment_status_employed": "angestellt",
|
||||||
|
|||||||
@ -1105,6 +1105,7 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"image_saved": "Image saved to Photos",
|
"image_saved": "Image saved to Photos",
|
||||||
"save_failed": "Could not save image",
|
"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": "%{n} members",
|
||||||
"member_count_online": "%{n} members · %{online} online",
|
"member_count_online": "%{n} members · %{online} online",
|
||||||
"pending_request": "Join requests",
|
"pending_request": "Join requests",
|
||||||
@ -1219,7 +1220,9 @@
|
|||||||
},
|
},
|
||||||
"privacy_section_title": "Privacy",
|
"privacy_section_title": "Privacy",
|
||||||
"show_online_status": "Show online status",
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "employed",
|
"employment_status_employed": "employed",
|
||||||
|
|||||||
@ -1025,6 +1025,7 @@
|
|||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"image_saved": "Image enregistrée dans Photos",
|
"image_saved": "Image enregistrée dans Photos",
|
||||||
"save_failed": "Impossible d'enregistrer l'image",
|
"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": "%{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",
|
||||||
@ -1138,7 +1139,9 @@
|
|||||||
},
|
},
|
||||||
"privacy_section_title": "Confidentialité",
|
"privacy_section_title": "Confidentialité",
|
||||||
"show_online_status": "Afficher le statut en ligne",
|
"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": {
|
"demographics": {
|
||||||
"employment_status_employed": "salarié",
|
"employment_status_employed": "salarié",
|
||||||
|
|||||||
@ -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>69</string>
|
<string>70</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>69</string>
|
<string>70</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>69</string>
|
<string>70</string>
|
||||||
<key>EXAppExtensionAttributes</key>
|
<key>EXAppExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>EXExtensionPointIdentifier</key>
|
<key>EXExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
"expo-image-manipulator": "~14.0.7",
|
"expo-image-manipulator": "~14.0.7",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
|
"expo-linear-gradient": "~15.0.8",
|
||||||
"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",
|
||||||
|
|||||||
@ -41,9 +41,12 @@ Building Release AAB (gradlew bundleRelease)|326
|
|||||||
Validating IPA (App-Store Connect)|86
|
Validating IPA (App-Store Connect)|86
|
||||||
Uploading zu App-Store Connect (TestFlight)|112
|
Uploading zu App-Store Connect (TestFlight)|112
|
||||||
Building Release AAB (gradlew bundleRelease)|272
|
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
|
Validating IPA (App-Store Connect)|117
|
||||||
Uploading zu App-Store Connect (TestFlight)|138
|
Uploading zu App-Store Connect (TestFlight)|138
|
||||||
Building Release AAB (gradlew bundleRelease)|273
|
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")
|
lastSeenAt DateTime? @map("last_seen_at")
|
||||||
presenceVisible Boolean @default(true) @map("presence_visible")
|
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) ─────────────────────────────
|
// ─── Voice-Quota (tages-basiert, UTC-Reset) ─────────────────────────────
|
||||||
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
|
// Tracked per plan: Free=60s/day, Pro=300s/day, Legend=unlimited (no tracking).
|
||||||
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
|
// voiceQuotaResetAt wird auf UTC-Mitternacht des aktuellen Tages gesetzt.
|
||||||
|
|||||||
@ -39,5 +39,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
globalBlocklistGraceUntil:
|
globalBlocklistGraceUntil:
|
||||||
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
||||||
presenceVisible: dbProfile?.presenceVisible ?? true,
|
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. */
|
/** 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(
|
export async function setPresenceVisible(
|
||||||
userId: string,
|
userId: string,
|
||||||
visible: boolean,
|
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:
|
expo-image-picker:
|
||||||
specifier: ~17.0.11
|
specifier: ~17.0.11
|
||||||
version: 17.0.11(expo@54.0.34)
|
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:
|
expo-linking:
|
||||||
specifier: ~8.0.12
|
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)
|
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: '*'
|
expo: '*'
|
||||||
react: '*'
|
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:
|
expo-linking@8.0.12:
|
||||||
resolution: {integrity: sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==}
|
resolution: {integrity: sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==}
|
||||||
peerDependencies:
|
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)
|
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: 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):
|
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:
|
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))
|
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