fix(native): phantom/zombie incoming calls (iOS) + DM online dot
Calls: an incoming call that ended without the in-app /call screen ever mounting (iOS shows the native CallKit banner, not our screen) left the call store stuck in 'ended' forever — the ended→idle reset only lived in the /call screen. A stuck 'ended' then blocked every subsequent incoming call (RING + VoIP push were received but dropped by the status!=='idle' guard), so accepting from the banner produced a phantom CallKit call that ticked as active with no connection, and the caller saw a missed call. - store self-heals back to 'idle' after a call ends (teardown fallback) - receiveIncoming + ring handler tolerate a stale 'ended' state - onAnswer ends the native CallKit call when store has no incoming call - RNCallKeep.endAllCalls() on launch clears leftover CallKit zombies DM online dot: the green avatar dot used follow-gated presence while the "online" text used raw presence → dot hidden for non-followed partners even when online. DM header avatar now uses raw presence (rawPresence prop) → consistent with the text on both platforms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
084f821bc5
commit
ac1d33afb8
9
apps/rebreak-native/NEXT_RELEASE.md
Normal file
9
apps/rebreak-native/NEXT_RELEASE.md
Normal file
@ -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.)
|
||||
@ -782,6 +782,7 @@ export default function DmScreen() {
|
||||
avatar={partner?.avatar ?? null}
|
||||
nickname={partner?.nickname ?? '?'}
|
||||
size="md"
|
||||
rawPresence
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flexShrink: 1 }}>
|
||||
|
||||
@ -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 (
|
||||
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -68,6 +68,11 @@ export async function setupCallKeep(): Promise<void> {
|
||||
);
|
||||
} 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);
|
||||
|
||||
@ -52,6 +52,11 @@ let callChan: RealtimeChannel | null = null;
|
||||
let pendingRemoteIce: any[] = [];
|
||||
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let incomingTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<CallState>((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, ')');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user