- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush - backend/push: log [push-token] register, [call-ring] receiver token-counts + expo-push-fanout for android-fallback - app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes 'foreground call flashes briefly then disappears' race when dm.tsx startCall set() hasn't propagated through useCallStore selector yet)
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
/**
|
|
* Push-Notifications via Expo Server SDK.
|
|
*
|
|
* Verwendet von:
|
|
* - backend/server/db/chat.ts sendDirectMessage() → triggert nach Insert
|
|
* - backend/server/db/chat-rooms.ts createRoomMessage() → triggert nach Insert
|
|
*
|
|
* Versand ist fire-and-forget (kein Block des HTTP-Response): Errors werden geloggt
|
|
* aber nicht propagiert, damit Chat-Send nicht failed wenn Expo-Server down ist.
|
|
*
|
|
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
|
|
* disabled (PushToken.enabled = false) — Re-Enable nur durch Re-Registrierung
|
|
* vom Client.
|
|
*/
|
|
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
|
|
import { usePrisma } from "../utils/prisma";
|
|
import { sendVoIPPush } from "./voip-push";
|
|
|
|
const expo = new Expo();
|
|
|
|
export interface ChatPushPayload {
|
|
/** Empfänger-User-ID (kann mehrere Tokens haben) */
|
|
receiverId: string;
|
|
/** Sender-Display-Name (für Titel "Max sendete: …") */
|
|
senderName: string;
|
|
/** Nachrichten-Vorschau (max ~100 Zeichen) */
|
|
preview: string;
|
|
/** Deep-Link-Daten — Client navigiert beim Tap */
|
|
data: {
|
|
type: "dm" | "room";
|
|
/** Bei DM: senderId, bei Room: roomId */
|
|
targetId: string;
|
|
messageId: string;
|
|
};
|
|
}
|
|
|
|
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
|
|
try {
|
|
const db = usePrisma();
|
|
|
|
// 1) Profile-Opt-out prüfen
|
|
const profile = await db.profile.findUnique({
|
|
where: { id: payload.receiverId },
|
|
select: { chatPushEnabled: true, deletedAt: true },
|
|
});
|
|
if (!profile || profile.deletedAt || !profile.chatPushEnabled) {
|
|
return;
|
|
}
|
|
|
|
// 2) Alle aktiven Tokens des Receivers
|
|
const tokens = await db.pushToken.findMany({
|
|
where: { userId: payload.receiverId, enabled: true },
|
|
select: { id: true, token: true },
|
|
});
|
|
if (tokens.length === 0) return;
|
|
|
|
// 3) Messages bauen (nur valide Expo-Tokens)
|
|
const messages: ExpoPushMessage[] = [];
|
|
const validTokenIds: string[] = [];
|
|
|
|
for (const t of tokens) {
|
|
if (!Expo.isExpoPushToken(t.token)) {
|
|
// Ungültiger Token-Format → disablen
|
|
await db.pushToken
|
|
.update({ where: { id: t.id }, data: { enabled: false } })
|
|
.catch(() => {});
|
|
continue;
|
|
}
|
|
messages.push({
|
|
to: t.token,
|
|
sound: "default",
|
|
title: payload.senderName,
|
|
body: payload.preview,
|
|
data: payload.data,
|
|
// Channel-ID für Android — wird in app.config.ts/native registriert
|
|
channelId: "chat",
|
|
});
|
|
validTokenIds.push(t.id);
|
|
}
|
|
|
|
if (messages.length === 0) return;
|
|
|
|
// 4) Senden in Chunks (Expo akzeptiert max 100 pro Request)
|
|
const chunks = expo.chunkPushNotifications(messages);
|
|
for (const chunk of chunks) {
|
|
try {
|
|
await expo.sendPushNotificationsAsync(chunk);
|
|
} catch (err) {
|
|
console.error("[push] chunk send failed:", err);
|
|
}
|
|
}
|
|
|
|
// 5) lastUsedAt bumpen (best-effort)
|
|
await db.pushToken
|
|
.updateMany({
|
|
where: { id: { in: validTokenIds } },
|
|
data: { lastUsedAt: new Date() },
|
|
})
|
|
.catch(() => {});
|
|
|
|
// Hinweis: Receipt-Polling (DeviceNotRegistered → token disablen) wird in
|
|
// einem separaten Cron-Job gemacht (TODO: scripts/push-receipts-cleanup.ts).
|
|
// Für MVP: Tokens bleiben aktiv bis sie explizit vom Client deregistriert
|
|
// werden oder Expo dauerhaft DeviceNotRegistered meldet.
|
|
} catch (err) {
|
|
console.error("[push] sendChatPush failed:", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper für Username-Lookup (für Push-Titel).
|
|
* Fällt auf "Jemand" zurück wenn kein nickname/username gesetzt.
|
|
*/
|
|
export async function getDisplayName(userId: string): Promise<string> {
|
|
const db = usePrisma();
|
|
const p = await db.profile.findUnique({
|
|
where: { id: userId },
|
|
select: { nickname: true, username: true },
|
|
});
|
|
return p?.nickname || p?.username || "Jemand";
|
|
}
|
|
|
|
/**
|
|
* Truncate für Push-Body (Expo hat 4kb hard limit, aber UI zeigt eh nur ~2 Zeilen).
|
|
*/
|
|
export function truncatePreview(text: string, max = 100): string {
|
|
if (text.length <= max) return text;
|
|
return text.slice(0, max - 1) + "…";
|
|
}
|
|
|
|
export interface CallRingPushPayload {
|
|
/** Empfänger (Callee) — bekommt den Push */
|
|
receiverId: string;
|
|
/** Caller-Display-Name (für Titel) */
|
|
callerName: string;
|
|
/** Caller-Profil (für Show-Screen nach Tap) */
|
|
callerId: string;
|
|
callerNickname: string;
|
|
callerAvatar: string | null;
|
|
/** Call-ID — muss matchen damit Callee an die richtige Session andocken kann */
|
|
callId: string;
|
|
}
|
|
|
|
/**
|
|
* Push für eingehenden Voice-Call.
|
|
*
|
|
* Foreground-Calls funktionieren via Supabase Realtime; dieser Push deckt den
|
|
* Background/locked-screen-Fall ab. Beim Tap navigiert der Client direkt in
|
|
* den /call-Screen und triggert `useCall.receiveIncoming(callId, from)` —
|
|
* die normale Accept/Decline-UI greift dann.
|
|
*
|
|
* Wichtig: KEIN VoIPPushKit (Apple-only, braucht CallKit). Wir nutzen den
|
|
* regulären APNs-Alert-Push mit `priority: high` + Notifications-Sound.
|
|
* Trade-off: Auf iOS wacht ein force-quit-killed Process NICHT auf — das ist
|
|
* dasselbe Verhalten wie reguläre Chat-Pushes. Für echte CallKit-Integration
|
|
* → Phase 2 (eigene Initiative).
|
|
*/
|
|
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
|
|
try {
|
|
const db = usePrisma();
|
|
|
|
const profile = await db.profile.findUnique({
|
|
where: { id: payload.receiverId },
|
|
select: { chatPushEnabled: true, deletedAt: true },
|
|
});
|
|
if (!profile || profile.deletedAt || !profile.chatPushEnabled) return;
|
|
|
|
const tokens = await db.pushToken.findMany({
|
|
where: { userId: payload.receiverId, enabled: true },
|
|
select: { id: true, token: true, voipToken: true, platform: true },
|
|
});
|
|
console.log(
|
|
`[call-ring] receiver=${payload.receiverId.slice(0,8)} tokens=${tokens.length} ` +
|
|
`(ios=${tokens.filter(t=>t.platform==='ios').length}/voip=${tokens.filter(t=>t.platform==='ios'&&t.voipToken).length}, ` +
|
|
`android=${tokens.filter(t=>t.platform==='android').length})`,
|
|
);
|
|
if (tokens.length === 0) return;
|
|
|
|
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ─────────────
|
|
// Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android,
|
|
// alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen
|
|
// und fällt auf den normalen Lockscreen-Banner zurück.
|
|
const voipHandledTokenIds = new Set<string>();
|
|
for (const t of tokens) {
|
|
if (t.platform === "ios" && t.voipToken) {
|
|
voipHandledTokenIds.add(t.id);
|
|
void sendVoIPPush({
|
|
voipToken: t.voipToken,
|
|
callId: payload.callId,
|
|
callerName: payload.callerName,
|
|
callerId: payload.callerId,
|
|
callerNickname: payload.callerNickname,
|
|
callerAvatar: payload.callerAvatar,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ────────────
|
|
// Wichtig: iOS-Devices die schon einen VoIP-Push bekommen haben werden
|
|
// hier ÜBERSPRUNGEN — sonst doppeltes Klingeln (CallKit + Banner).
|
|
const messages: ExpoPushMessage[] = [];
|
|
const validTokenIds: string[] = [];
|
|
|
|
for (const t of tokens) {
|
|
if (voipHandledTokenIds.has(t.id)) continue;
|
|
if (!Expo.isExpoPushToken(t.token)) {
|
|
await db.pushToken
|
|
.update({ where: { id: t.id }, data: { enabled: false } })
|
|
.catch(() => {});
|
|
continue;
|
|
}
|
|
messages.push({
|
|
to: t.token,
|
|
sound: "default",
|
|
title: `📞 ${payload.callerName}`,
|
|
body: "Eingehender Anruf",
|
|
priority: "high",
|
|
// Android: dedizierter Calls-Channel (im Client mit Importance.MAX +
|
|
// Vibration + Bypass-DND zu konfigurieren)
|
|
channelId: "calls",
|
|
// iOS: zeigt als alert auch im Lockscreen (interruption-level)
|
|
// 'time-sensitive' wäre besser, braucht aber Critical-Alerts-Entitlement.
|
|
// 'active' (default) reicht für Phase 1.
|
|
data: {
|
|
type: "call",
|
|
callId: payload.callId,
|
|
from: {
|
|
id: payload.callerId,
|
|
nickname: payload.callerNickname,
|
|
avatar: payload.callerAvatar,
|
|
},
|
|
},
|
|
});
|
|
validTokenIds.push(t.id);
|
|
}
|
|
|
|
if (messages.length === 0) return;
|
|
console.log(`[call-ring] expo-push to ${messages.length} non-voip token(s) for receiver=${payload.receiverId.slice(0,8)}`);
|
|
|
|
const chunks = expo.chunkPushNotifications(messages);
|
|
for (const chunk of chunks) {
|
|
try {
|
|
await expo.sendPushNotificationsAsync(chunk);
|
|
} catch (err) {
|
|
console.error("[push] call-ring chunk send failed:", err);
|
|
}
|
|
}
|
|
|
|
await db.pushToken
|
|
.updateMany({
|
|
where: { id: { in: validTokenIds } },
|
|
data: { lastUsedAt: new Date() },
|
|
})
|
|
.catch(() => {});
|
|
} catch (err) {
|
|
console.error("[push] sendCallRingPush failed:", err);
|
|
}
|
|
}
|