diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx
index e85a620..683110e 100644
--- a/apps/rebreak-native/app/dm.tsx
+++ b/apps/rebreak-native/app/dm.tsx
@@ -320,6 +320,23 @@ export default function DmScreen() {
// Typing-Indicator (ephemerer Broadcast, kein DB-Write)
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
+ // Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled).
+ // Steuert Sichtbarkeit des Call-Buttons im Header.
+ const { data: canCallData } = useQuery({
+ queryKey: ['can-call', userId],
+ queryFn: () => apiFetch<{ canCall: boolean }>(`/api/chat/can-call/${userId}`),
+ enabled: !!userId && !!myUserId,
+ staleTime: 60_000,
+ });
+ const canCall = canCallData?.canCall ?? false;
+
+ function startCall() {
+ // TODO(phase1): echte Call-Engine (WebRTC + coturn + Signaling). Bis der
+ // TURN-Server steht + ein Dev-Build mit react-native-webrtc existiert, hier
+ // nur ein ehrlicher Hinweis statt eines toten Buttons.
+ Alert.alert(t('chat.call'), t('chat.call_coming_soon'));
+ }
+
async function pickImage() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
@@ -750,11 +767,11 @@ export default function DmScreen() {
- {/* Avatar + Name — tap → Profil */}
+ {/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */}
userId && router.push(`/profile/${userId}` as any)}
+ onPress={() => setInfoSheetOpen(true)}
>
-
- {partner?.nickname ?? '…'}
-
+
+
+ {partner?.nickname ?? '…'}
+
+
+
{userId && }
- {/* Info-Button */}
- setInfoSheetOpen(true)}
- >
-
-
+ {/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */}
+ {canCall && (
+
+
+
+ )}
diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json
index cfa2e22..e7e9867 100644
--- a/apps/rebreak-native/locales/ar.json
+++ b/apps/rebreak-native/locales/ar.json
@@ -1037,6 +1037,8 @@
"image_saved": "تم حفظ الصورة في الصور",
"save_failed": "تعذّر حفظ الصورة",
"save_needs_rebuild": "الحفظ يحتاج تحديث التطبيق — أعد المحاولة بعد البناء التالي.",
+ "call": "اتصال",
+ "call_coming_soon": "المكالمات الصوتية قادمة قريباً — نحن نبني هذه الميزة.",
"member_count": "%{n} أعضاء",
"member_count_online": "%{n} أعضاء · %{online} متصل",
"pending_request": "طلبات الانضمام",
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index a415d14..cac572c 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -1108,6 +1108,8 @@
"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.",
+ "call": "Anruf",
+ "call_coming_soon": "Sprachanrufe kommen bald — wir bauen die Funktion gerade.",
"member_count": "%{n} Mitglieder",
"member_count_online": "%{n} Mitglieder · %{online} online",
"pending_request": "Beitrittsanfragen",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 8b561c2..83870b6 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -1106,6 +1106,8 @@
"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.",
+ "call": "Call",
+ "call_coming_soon": "Voice calls are coming soon — we're building this feature.",
"member_count": "%{n} members",
"member_count_online": "%{n} members · %{online} online",
"pending_request": "Join requests",
diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json
index 27b56ff..902a0a9 100644
--- a/apps/rebreak-native/locales/fr.json
+++ b/apps/rebreak-native/locales/fr.json
@@ -1026,6 +1026,8 @@
"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.",
+ "call": "Appel",
+ "call_coming_soon": "Les appels vocaux arrivent bientôt — nous développons cette fonction.",
"member_count": "%{n} membres",
"member_count_online": "%{n} membres · %{online} en ligne",
"pending_request": "Demandes d'adhésion",
diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts
index 32e3d52..d1459dc 100644
--- a/backend/nitro.config.ts
+++ b/backend/nitro.config.ts
@@ -73,6 +73,17 @@ export default defineNitroConfig({
process.env.SUPABASE_SERVICE_ROLE_KEY ??
"",
+ // ─── Voice-Calls / TURN (coturn, self-hosted) ───────────────────────
+ // Ephemere TURN-Credentials werden vom Backend per HMAC aus TURN_SECRET
+ // gemintet (coturn `use-auth-secret`). Host = coturn-Domain, Realm = coturn-
+ // realm. In Infisical setzen, BEVOR /api/calls/ice-servers genutzt wird:
+ // TURN_HOST (z.B. turn.rebreak.org)
+ // TURN_SECRET (shared secret, identisch zu coturn static-auth-secret)
+ // TURN_REALM (z.B. rebreak.org)
+ turnHost: process.env.TURN_HOST ?? "",
+ turnSecret: process.env.TURN_SECRET ?? "",
+ turnRealm: process.env.TURN_REALM ?? "rebreak.org",
+
// ─── Stripe ──────────────────────────────────────────────────────────
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
diff --git a/backend/server/api/calls/ice-servers.get.ts b/backend/server/api/calls/ice-servers.get.ts
new file mode 100644
index 0000000..288c54a
--- /dev/null
+++ b/backend/server/api/calls/ice-servers.get.ts
@@ -0,0 +1,52 @@
+/**
+ * GET /api/calls/ice-servers
+ *
+ * Liefert ephemere TURN-Credentials für eine WebRTC-Audio-Verbindung.
+ * coturn läuft mit `use-auth-secret` (TURN-REST-API-Schema):
+ * username = ":"
+ * credential = base64( HMAC-SHA1(TURN_SECRET, username) )
+ * coturn akzeptiert das ohne DB-Lookup, die Credentials laufen nach TTL ab.
+ *
+ * Privacy: iceTransportPolicy = "relay" → der Client tauscht NIE direkte
+ * Host-IPs aus, alles läuft über coturn. Ist TURN nicht konfiguriert, wird
+ * bewusst 503 geworfen (kein IP-leakender STUN-only-Fallback).
+ *
+ * Response: { iceServers: RTCIceServer[], iceTransportPolicy: "relay", ttl: number }
+ */
+import { createHmac } from "node:crypto";
+import { requireUser } from "../../utils/auth";
+
+const TTL_SECONDS = 600; // 10 min — länger als ein typischer Call-Aufbau
+
+export default defineEventHandler(async (event) => {
+ const user = await requireUser(event);
+
+ const config = useRuntimeConfig(event);
+ const host = config.turnHost as string;
+ const secret = config.turnSecret as string;
+
+ if (!host || !secret) {
+ // coturn/Secrets noch nicht eingerichtet → Calls sind nicht verfügbar.
+ throw createError({ statusCode: 503, statusMessage: "calls_not_configured" });
+ }
+
+ const expiry = Math.floor(Date.now() / 1000) + TTL_SECONDS;
+ const username = `${expiry}:${user.id}`;
+ const credential = createHmac("sha1", secret).update(username).digest("base64");
+
+ return {
+ iceServers: [
+ {
+ urls: [
+ `turn:${host}:3478?transport=udp`,
+ `turn:${host}:3478?transport=tcp`,
+ `turns:${host}:5349?transport=tcp`,
+ ],
+ username,
+ credential,
+ },
+ ],
+ iceTransportPolicy: "relay" as const,
+ ttl: TTL_SECONDS,
+ };
+});
diff --git a/ops/calls/RUNBOOK.md b/ops/calls/RUNBOOK.md
new file mode 100644
index 0000000..465295d
--- /dev/null
+++ b/ops/calls/RUNBOOK.md
@@ -0,0 +1,58 @@
+# Voice-Calls — coturn TURN-Server Runbook
+
+Self-hosted TURN (coturn) auf Hetzner für die DM-Voice-Calls. Force-Relay →
+kein direkter IP-Austausch zwischen Usern (Anonymität, kein neuer
+Sub-Auftragsverarbeiter).
+
+## Architektur
+
+```
+App A ──┐ ┌── App B
+ │ WebRTC (DTLS-SRTP, E2E) │
+ ├──────────────► coturn ◄──────┤ ← relay-only, sieht nur verschlüsselten Audio-Stream
+ │ │
+ Signaling (SDP/ICE) über Supabase Realtime (ephemerer Channel pro Call)
+ ICE-Credentials über GET /api/calls/ice-servers (HMAC, 10-min-TTL)
+```
+
+- coturn validiert ephemere Credentials per `use-auth-secret` (kein DB-Lookup).
+- Das Backend mintet sie aus `TURN_SECRET` = coturn `static-auth-secret`.
+
+## Provisioning (einmalig)
+
+> ⚠️ Destruktive/Infra-Schritte — nur mit User-GO ausführen. Secrets NIE
+> committen, nur in Infisical (staging).
+
+1. **DNS**: A-Record `turn.rebreak.org` → Server-IP (Hetzner).
+2. **Install**: `apt update && apt install -y coturn`
+3. **TLS-Cert**: `certbot certonly --standalone -d turn.rebreak.org`
+ (Port 80 muss kurz frei sein). Auto-Renew via certbot-Timer.
+4. **Config**: `ops/calls/turnserver.conf` → `/etc/turnserver.conf`.
+ `static-auth-secret` durch das echte Secret ersetzen (siehe Schritt 6).
+5. **Firewall** (Hetzner Cloud Firewall + ufw): öffnen
+ - `3478/udp`, `3478/tcp` (STUN/TURN)
+ - `5349/tcp` (TURN over TLS)
+ - `49160-49200/udp` (Relay-Range, = min/max-port in der Config)
+6. **Secret generieren** + in Infisical (env=staging) setzen:
+ - `TURN_SECRET` = `openssl rand -hex 32` (gleicher Wert in turnserver.conf)
+ - `TURN_HOST` = `turn.rebreak.org`
+ - `TURN_REALM` = `rebreak.org`
+7. **Enable**: in `/etc/default/coturn` → `TURNSERVER_ENABLED=1`, dann
+ `systemctl enable --now coturn`.
+8. **Verify**:
+ - `ss -lunp | grep 3478` (lauscht UDP)
+ - `turnutils_uclient -v -t -u -w turn.rebreak.org` (oder
+ Trickle-ICE-Test im Browser gegen turns:turn.rebreak.org:5349).
+
+## Backend
+
+- `GET /api/calls/ice-servers` liefert `{ iceServers, iceTransportPolicy:"relay", ttl }`.
+- Wirft `503 calls_not_configured`, solange `TURN_HOST`/`TURN_SECRET` fehlen →
+ kein IP-leakender Fallback.
+- runtimeConfig-Keys in `backend/nitro.config.ts`: `turnHost`, `turnSecret`, `turnRealm`.
+
+## Betrieb / Kosten
+
+- Audio ≈ 50 kbps/Call → Bandbreite vernachlässigbar.
+- coturn kann auf der bestehenden Hetzner-Box mitlaufen (eigene Ports).
+- Log-Minimierung: coturn `no-cli`, kein verbose-Logging in Prod.
diff --git a/ops/calls/turnserver.conf b/ops/calls/turnserver.conf
new file mode 100644
index 0000000..200d832
--- /dev/null
+++ b/ops/calls/turnserver.conf
@@ -0,0 +1,40 @@
+# coturn — Rebreak Voice-Calls TURN-Server (self-hosted)
+# ────────────────────────────────────────────────────────────────────────────
+# Ephemere Credentials via use-auth-secret (TURN-REST-API). Das Backend mintet
+# username/credential per HMAC-SHA1 aus `static-auth-secret` — coturn validiert
+# ohne DB. static-auth-secret MUSS exakt = Infisical TURN_SECRET sein.
+#
+# Force-Relay-Design: Clients nutzen iceTransportPolicy:"relay" → coturn ist die
+# EINZIGE Vermittlung, Peers sehen nie die fremde IP (Anonymität).
+
+listening-port=3478
+tls-listening-port=5349
+fingerprint
+
+# ─── Auth (ephemere HMAC-Credentials) ──────────────────────────────────────
+use-auth-secret
+static-auth-secret=__SET_TO_INFISICAL_TURN_SECRET__
+realm=rebreak.org
+
+# ─── Relay-Port-Range (Firewall: diese UDP-Range öffnen) ────────────────────
+min-port=49160
+max-port=49200
+
+# ─── TLS (turns:// auf 5349) — Let's Encrypt für turn.rebreak.org ───────────
+cert=/etc/letsencrypt/live/turn.rebreak.org/fullchain.pem
+pkey=/etc/letsencrypt/live/turn.rebreak.org/privkey.pem
+
+# ─── Hardening ──────────────────────────────────────────────────────────────
+no-cli
+no-multicast-peers
+no-tcp-relay
+# SSRF-Schutz: Relay zu privaten/Loopback-Netzen verbieten
+denied-peer-ip=0.0.0.0-0.255.255.255
+denied-peer-ip=10.0.0.0-10.255.255.255
+denied-peer-ip=127.0.0.0-127.255.255.255
+denied-peer-ip=169.254.0.0-169.254.255.255
+denied-peer-ip=172.16.0.0-172.31.255.255
+denied-peer-ip=192.168.0.0-192.168.255.255
+
+# Hetzner hat i.d.R. eine öffentliche IP direkt am Interface. Falls hinter NAT:
+# external-ip=