diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md new file mode 100644 index 0000000..85ca870 --- /dev/null +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -0,0 +1,9 @@ +# Next Release + +## Fixes +- **Calls: fixed phantom/zombie incoming calls (iOS).** After an incoming call ended without the in-app call screen ever mounting (iOS shows the native CallKit banner, not our `/call` screen), the call store stayed stuck in the `ended` state forever. The `ended → idle` reset only lived in the `/call` screen, which never mounts for banner-only incoming calls. A stuck `ended` state then silently blocked every subsequent incoming call (RING + VoIP push were received but ignored by the `status !== 'idle'` guard), so accepting from the banner produced a phantom CallKit call that ticked as "active" with no real connection, and the caller saw a missed call. + - Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`). + - `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call. + - `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call. + - `RNCallKeep.endAllCalls()` on app launch clears leftover CallKit zombies from a previous session. +- **DM header: online dot now matches the online text.** The green online dot on the partner avatar used the follow-gated presence (`isOnline` = online AND you follow them), while the "online" text next to it used raw presence. In a DM the dot now uses raw presence too, so it shows whenever the partner is online — consistent with the text, regardless of follow relationship. (Looked like an Android-only bug but was the follow gate + asymmetric follow between the test accounts.) diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index f5ff9d7..6e57323 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -782,6 +782,7 @@ export default function DmScreen() { avatar={partner?.avatar ?? null} nickname={partner?.nickname ?? '?'} size="md" + rawPresence /> diff --git a/apps/rebreak-native/components/UserAvatar.tsx b/apps/rebreak-native/components/UserAvatar.tsx index ce5d82d..ba6469c 100644 --- a/apps/rebreak-native/components/UserAvatar.tsx +++ b/apps/rebreak-native/components/UserAvatar.tsx @@ -14,6 +14,10 @@ type Props = { size?: Size; showOnlineIndicator?: boolean; isBot?: boolean; + // Online-Punkt OHNE Follow-Gate (rohe Presence). Für Kontexte wo die Beziehung + // bereits etabliert ist (z.B. DM-Header) — sonst zeigt der Punkt nur bei + // gefolgten Usern, was inkonsistent zum „online"-Text wäre (der ist ungated). + rawPresence?: boolean; }; const SIZE_MAP: Record< @@ -60,9 +64,10 @@ export function UserAvatar({ size = 'md', showOnlineIndicator = true, isBot = false, + rawPresence = false, }: Props) { const colors = useColors(); - const { isOnline } = useOnlineUsers(); + const { isOnline, onlineUserIds } = useOnlineUsers(); const [imageFailed, setImageFailed] = useState(false); const s = SIZE_MAP[size]; @@ -76,7 +81,7 @@ export function UserAvatar({ showOnlineIndicator !== false && !!userId && !isBot && - isOnline(userId); + (rawPresence ? onlineUserIds.has(userId) : isOnline(userId)); return ( diff --git a/apps/rebreak-native/hooks/useCallKeepEvents.ts b/apps/rebreak-native/hooks/useCallKeepEvents.ts index 3749fd0..a29d9dc 100644 --- a/apps/rebreak-native/hooks/useCallKeepEvents.ts +++ b/apps/rebreak-native/hooks/useCallKeepEvents.ts @@ -62,9 +62,16 @@ export function useCallKeepEvents() { // User tippt "Annehmen" in der CallKit-/ConnectionService-UI const onAnswer = ({ callUUID }: { callUUID: string }) => { - console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState); const st = useCallStore.getState(); - if (st.status !== 'incoming') return; + console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState, 'storeStatus=', st.status); + if (st.status !== 'incoming') { + // CallKit hat den Call nativ bereits auf 'aktiv' gesetzt (Banner \u2192 Timer). + // Ohne 'incoming'-State im Store k\u00f6nnen wir nicht joinen \u2192 es entst\u00fcnde ein + // Phantom-Call der ewig \u201eaktiv" tickt. Sauber beenden statt nur return. + console.log('[callkeep] answer but store not incoming \u2192 endAllCalls to avoid phantom'); + try { RNCallKeep.endAllCalls(); } catch {} + return; + } // Call-Screen \u00f6ffnen und Accept-Flow triggern. router.push('/call'); void st.acceptCall(); diff --git a/apps/rebreak-native/hooks/useIncomingCalls.ts b/apps/rebreak-native/hooks/useIncomingCalls.ts index 6acf1b7..2bb3514 100644 --- a/apps/rebreak-native/hooks/useIncomingCalls.ts +++ b/apps/rebreak-native/hooks/useIncomingCalls.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { Platform } from 'react-native'; import { useRouter } from 'expo-router'; import { supabase } from '../lib/supabase'; import { useCallStore, type CallPeer } from '../stores/call'; @@ -22,10 +23,20 @@ export function useIncomingCalls(myUserId: string | undefined) { const callId = msg?.payload?.callId as string | undefined; const from = msg?.payload?.from as CallPeer | undefined; if (!callId || !from) return; - // Schon in einem Call → ignorieren (MVP: kein call-waiting). - if (useCallStore.getState().status !== 'idle') return; + // Schon in einem AKTIVEN Call → ignorieren (MVP: kein call-waiting). + // 'ended' ist KEIN aktiver Call (nur ein noch nicht aufgeräumter Rest eines + // vorherigen Calls) → durchlassen, receiveIncoming räumt ihn auf. Sonst + // blockt ein stale 'ended' jeden neuen Anruf. + const st = useCallStore.getState().status; + if (st !== 'idle' && st !== 'ended') return; useCallStore.getState().receiveIncoming(callId, from); - router.push('/call'); + // iOS: CallKit (via VoIP-Push → reportNewIncomingCall) IST die Eingehend-UI. + // Hier NICHT zu /call navigieren, sonst Doppel-UI (CallKit-Banner + + // Fullscreen-/call). Der Realtime-Ring dient auf iOS nur der Store- + // Hydration; der /call-Screen kommt erst nach „Annehmen" (CallKit → + // useCallKeepEvents.onAnswer → router.push('/call')). + // Android: Custom-/call ist die gewünschte Eingehend-UI. + if (Platform.OS !== 'ios') router.push('/call'); }); chan.on('broadcast', { event: 'cancel' }, (msg: any) => { const callId = msg?.payload?.callId as string | undefined; diff --git a/apps/rebreak-native/lib/callkit.ts b/apps/rebreak-native/lib/callkit.ts index 46ede73..fe6277d 100644 --- a/apps/rebreak-native/lib/callkit.ts +++ b/apps/rebreak-native/lib/callkit.ts @@ -68,6 +68,11 @@ export async function setupCallKeep(): Promise { ); } catch {} } + // Zombie-Cleanup beim App-Start: Ein vorheriger Call der nie sauber via + // endCall beendet wurde (z.B. Phantom nach Accept-ohne-Join) bleibt sonst in + // iOS-CallKit ewig „aktiv". setupCallKeep läuft nur einmal pro Launch + // (didSetup-Guard), daher kein Risiko einen legitimen Live-Call zu killen. + try { RNCallKeep.endAllCalls(); } catch {} didSetup = true; } catch (e: any) { console.warn('[callkeep] setup failed', e?.message ?? e); diff --git a/apps/rebreak-native/stores/call.ts b/apps/rebreak-native/stores/call.ts index 94db172..3d38011 100644 --- a/apps/rebreak-native/stores/call.ts +++ b/apps/rebreak-native/stores/call.ts @@ -52,6 +52,11 @@ let callChan: RealtimeChannel | null = null; let pendingRemoteIce: any[] = []; let unansweredTimer: ReturnType | null = null; let incomingTimer: ReturnType | null = null; +// Self-Heal-Fallback: setzt den Store nach einem beendeten Call zurück auf 'idle', +// FALLS der /call-Screen das nicht tut (iOS-Banner-only Incoming der ohne Mount +// des Screens endet). Ohne das bleibt status='ended' hängen und blockt jeden +// Folge-Call (RING-Guard `!== idle`). +let endedResetTimer: ReturnType | null = null; let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging let currentRole: 'caller' | 'callee' | null = null; let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log @@ -118,6 +123,21 @@ function teardown() { pc = null; localStream = null; pendingRemoteIce = []; + // Self-Heal: Wenn der Call auf 'ended' steht (hangup/decline/unanswered) und + // der /call-Screen ihn nicht innerhalb seiner 1300ms-Logik auf 'idle' zieht + // (weil er bei iOS-Incoming-Banner nie gemountet wurde), würde der Store ewig + // 'ended' bleiben → ALLE Folge-Calls blockiert. Entkoppelter Fallback-Reset. + if (endedResetTimer) { clearTimeout(endedResetTimer); endedResetTimer = null; } + endedResetTimer = setTimeout(() => { + endedResetTimer = null; + const s = useCallStore.getState(); + if (s.status === 'ended') { + useCallStore.setState({ + status: 'idle', peer: null, callId: null, + muted: false, speaker: false, startedAt: null, endReason: null, + }); + } + }, 2500); } // DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate). @@ -381,7 +401,13 @@ export const useCallStore = create((set, get) => { // Schon im Gespräch / oder bereits am Klingeln mit derselben callId? // → ignorieren (dedup: Realtime + VoIP-Push können beide feuern). const cur = get(); - if (cur.status !== 'idle') { + if (cur.status === 'ended') { + // Stale 'ended' von einem vorherigen Call (z.B. iOS-Banner-Incoming der + // ohne /call-Screen endete) → als 'idle' behandeln, sonst blockt er den + // neuen Call. Self-Heal-Timer abbrechen, wir übernehmen jetzt. + clog('receiveIncoming: clearing stale ended-state for new call', callId); + if (endedResetTimer) { clearTimeout(endedResetTimer); endedResetTimer = null; } + } else if (cur.status !== 'idle') { if (cur.status === 'incoming' && cur.callId === callId) { clog('receiveIncoming dedup (already incoming for', callId, ')'); }