import { create } from 'zustand'; import { wsService, WSCallIncomingMessage, WSErrorMessage, api, } from '@/services/core'; import { liveKitService } from '@/services/livekit'; import { callKeepService } from '@/services/callkeep'; import type { IncomingCallEvent, CallSession as SystemCallSession } from 'expo-callkit-telecom'; import { getCurrentUserId } from '../auth/sessionStore'; import { useUserStore } from '../userStore'; import { userManager } from '../user/UserManager'; export type CallStatus = | 'idle' | 'calling' | 'ringing' | 'connecting' | 'connected' | 'reconnecting' | 'ended' | 'failed'; export type CallType = 'voice' | 'video'; export interface CallParticipant { id: string; name: string; avatar?: string | null; isMuted: boolean; isVideoEnabled: boolean; isLocal: boolean; isReady: boolean; joinedAt: number; } export interface CallSession { id: string; conversationId: string; groupId?: string; peerId: string; peerName?: string; peerAvatar?: string | null; status: CallStatus; callType: CallType; mediaType: 'voice' | 'video'; startedAt?: number; duration: number; isMuted: boolean; isSpeakerOn: boolean; isVideoEnabled: boolean; isPeerVideoEnabled: boolean; isInitiator: boolean; isPeerReady: boolean; participants: Map; activeSpeakerId: string | null; pinnedParticipantId: string | null; } export interface IncomingCallInfo { callId: string; conversationId: string; callerId: string; callerName?: string; callerAvatar?: string | null; callType: string; receivedAt: number; lifetime?: number; } interface CallState { currentCall: CallSession | null; incomingCall: IncomingCallInfo | null; callDuration: number; isMinimized: boolean; initCall: () => () => void; startCall: ( conversationId: string, calleeId: string, calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }, callType?: CallType ) => Promise; acceptCall: () => Promise; rejectCall: () => void; endCall: (reason?: string) => Promise; toggleMute: () => void; toggleSpeaker: () => void; toggleMinimize: () => void; toggleVideo: () => Promise; setVideoEnabled: (enabled: boolean) => Promise; handleSystemAnswer: () => Promise; handleIncomingFromPush: (session: SystemCallSession) => void; handleSystemMuted: (muted: boolean) => void; startGroupCall: ( groupId: string, conversationId: string, callType: CallType ) => Promise; addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => void; removeParticipant: (userId: string) => void; setActiveSpeaker: (userId: string | null) => void; pinParticipant: (userId: string | null) => void; updateParticipantState: (userId: string, updates: Partial) => void; reset: () => void; } // Module-level variables for timers let durationTimer: ReturnType | null = null; let callTimeoutTimer: ReturnType | null = null; let initCallUnsub: (() => void) | null = null; let unsubInvited: (() => void) | null = null; let liveKitUnsubs: (() => void)[] = []; let pendingVideoEnabled: boolean | null = null; // Constants const CALL_LIFETIME_MS = 55000; const CALL_TIMEOUT_MS = 115000; const IGNORE_CALL_ID_TTL = 30000; const processedCallIds = new Map(); function cleanupProcessedCallIds() { const now = Date.now(); for (const [callId, timestamp] of processedCallIds) { if (now - timestamp > IGNORE_CALL_ID_TTL) { processedCallIds.delete(callId); } } } /** * Build initial participants map for a new call session. */ function buildInitialParticipants( localUserId: string, localName: string, calleeId: string, calleeName: string, calleeAvatar?: string | null, ): Map { const m = new Map(); const now = Date.now(); m.set(localUserId, { id: localUserId, name: localName, avatar: null, isMuted: false, isVideoEnabled: false, isLocal: true, isReady: true, joinedAt: now, }); m.set(calleeId, { id: calleeId, name: calleeName, avatar: calleeAvatar ?? null, isMuted: false, isVideoEnabled: false, isLocal: false, isReady: false, joinedAt: 0, }); return m; } /** * Fetch LiveKit token from the backend and connect to the room. * Connects audio-only first, then enables video separately. */ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise { const res = await api.get<{ token: string; url: string }>('/calls/token', { room: callId, }); const { token, url } = res.data; if (!token || !url) { throw new Error('Invalid LiveKit token response'); } // Store video preference for enabling after connection pendingVideoEnabled = videoEnabled; // Configure RTCAudioSession for the call type before connecting callKeepService.configureAudioForCall(videoEnabled); setupLiveKitEvents(callId); // Always connect as audio-only first await liveKitService.connect(url, token); // Enable microphone await liveKitService.setMuted(false); // Video will be enabled by enablePendingVideo() called from connected event } /** * Enable video after a delay to avoid iOS main thread deadlock. * Called from the connected event handler. */ function enablePendingVideo(): void { if (!pendingVideoEnabled) return; const enable = pendingVideoEnabled; pendingVideoEnabled = null; // Use a longer timeout to let the JS thread fully settle setTimeout(() => { liveKitService.setVideoEnabled(enable).catch((err) => { console.warn('[CallStore] Failed to enable video:', err); }); }, 1000); } /** * Set up LiveKit room event handlers. */ function setupLiveKitEvents(callId: string): void { // Clean up any existing subscriptions liveKitUnsubs.forEach((unsub) => unsub()); liveKitUnsubs = []; liveKitUnsubs.push( liveKitService.on('connected', () => { console.log('[CallStore] LiveKit connected'); const now = Date.now(); callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, status: 'connected', startedAt: now } : null, })); wsService.sendCallReady(callId); // Start duration timer immediately if (durationTimer) clearInterval(durationTimer); durationTimer = setInterval(() => { callStore.setState((s) => ({ callDuration: s.callDuration + 1 })); }, 1000); // Schedule video enable after settling (avoids iOS deadlock) enablePendingVideo(); // Sync with system CallKit/Telecom UI callKeepService.fulfillIncomingCallConnected(); callKeepService.reportOutgoingCallConnected(); }) ); liveKitUnsubs.push( liveKitService.on('disconnected', () => { console.log('[CallStore] LiveKit disconnected'); const { currentCall } = callStore.getState(); if (currentCall && currentCall.status !== 'ended' && currentCall.status !== 'failed') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, status: 'reconnecting' } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('reconnecting', () => { console.log('[CallStore] LiveKit reconnecting'); callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, status: 'reconnecting' } : null, })); }) ); liveKitUnsubs.push( liveKitService.on('reconnected', () => { console.log('[CallStore] LiveKit reconnected'); callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, status: 'connected' } : null, })); }) ); liveKitUnsubs.push( liveKitService.on('connectionStatusChanged', (status) => { console.log('[CallStore] LiveKit connection status:', status); const { currentCall } = callStore.getState(); if (!currentCall) return; if (status === 'failed') { callStore.getState().endCall('connection_failed'); } }) ); liveKitUnsubs.push( liveKitService.on('trackSubscribed', ({ track }) => { console.log('[CallStore] Remote track subscribed:', track.kind); if (track.kind === 'video') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isPeerVideoEnabled: true } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('trackUnsubscribed', ({ track }) => { console.log('[CallStore] Remote track unsubscribed:', track.kind); if (track.kind === 'video') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isPeerVideoEnabled: false } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('trackMuted', ({ publication }) => { if (publication.source === 'camera') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isPeerVideoEnabled: false } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('trackUnmuted', ({ publication }) => { if (publication.source === 'camera') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isPeerVideoEnabled: true } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('error', (err) => { console.error('[CallStore] LiveKit error:', err); }) ); liveKitUnsubs.push( liveKitService.on('activeSpeakersChanged', ({ speakers }) => { const activeId = speakers.length > 0 ? speakers[0].identity : null; callStore.getState().setActiveSpeaker(activeId); }) ); liveKitUnsubs.push( liveKitService.on('participantConnected', ({ participant }) => { console.log('[CallStore] Remote participant connected:', participant.identity); callStore.setState((s) => { if (!s.currentCall) return {}; const newParticipants = new Map(s.currentCall.participants); if (!newParticipants.has(participant.identity)) { newParticipants.set(participant.identity, { id: participant.identity, name: participant.name || participant.identity, avatar: null, isMuted: false, isVideoEnabled: false, isLocal: false, isReady: true, joinedAt: Date.now(), }); } return { currentCall: { ...s.currentCall, participants: newParticipants } }; }); }) ); liveKitUnsubs.push( liveKitService.on('participantDisconnected', ({ participant }) => { console.log('[CallStore] Remote participant disconnected:', participant.identity); callStore.getState().removeParticipant(participant.identity); }) ); liveKitUnsubs.push( liveKitService.on('localTrackPublished', ({ publication }) => { if (publication.source === 'camera') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isVideoEnabled: true } : null, })); } }) ); liveKitUnsubs.push( liveKitService.on('localTrackUnpublished', ({ publication }) => { if (publication.source === 'camera') { callStore.setState((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isVideoEnabled: false } : null, })); } }) ); } /** * Clean up all resources. */ function cleanupResources(): void { if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } if (durationTimer) { clearInterval(durationTimer); durationTimer = null; } liveKitUnsubs.forEach((unsub) => unsub()); liveKitUnsubs = []; } export const callStore = create((set, get) => ({ currentCall: null, incomingCall: null, callDuration: 0, isMinimized: false, initCall: () => { if (initCallUnsub) { initCallUnsub(); initCallUnsub = null; } // Initialize CallKit/Telecom service with system UI callbacks callKeepService.initialize({ onOutgoingStarted: () => { console.log('[CallStore] System outgoing call started'); }, onSystemAnswered: () => { console.log('[CallStore] System answered'); get().acceptCall(); }, onSystemEnded: () => { console.log('[CallStore] System ended call'); get().endCall('system_ended'); }, onSystemMuted: (muted: boolean) => { get().toggleMute(); }, onIncomingCallFromPush: (_session) => { // Incoming call from push when app was killed — already handled by WS event console.log('[CallStore] Incoming call from push (already handled by WS)'); }, }); const unsubs: Array<() => void> = []; unsubs.push( wsService.on('call_incoming', async (msg) => { cleanupProcessedCallIds(); if (processedCallIds.has(msg.call_id)) { console.log('[CallStore] Ignoring already processed call:', msg.call_id); return; } const { currentCall, incomingCall } = get(); if (incomingCall) { wsService.sendCallBusy(msg.call_id); processedCallIds.set(msg.call_id, Date.now()); return; } if (currentCall && currentCall.status !== 'idle') { wsService.sendCallBusy(msg.call_id); processedCallIds.set(msg.call_id, Date.now()); return; } const callAge = Date.now() - msg.created_at; const lifetime = msg.lifetime || 60000; if (callAge > lifetime - 5000) { console.log('[CallStore] Ignoring stale incoming call, age:', callAge); wsService.sendCallReject(msg.call_id); processedCallIds.set(msg.call_id, Date.now()); return; } let caller: { nickname?: string; username?: string; avatar?: string | null } | null = useUserStore.getState().userCache[msg.caller_id]; if (!caller) { try { const fetchedCaller = await userManager.getUserById(msg.caller_id); if (fetchedCaller) { caller = { nickname: fetchedCaller.nickname, username: fetchedCaller.username, avatar: fetchedCaller.avatar, }; } } catch (err) { console.log('[CallStore] Failed to fetch caller info:', err); } } const callerName = caller?.nickname || caller?.username || msg.caller_id; set({ incomingCall: { callId: msg.call_id, conversationId: msg.conversation_id, callerId: msg.caller_id, callerName, callerAvatar: caller?.avatar, callType: msg.call_type, receivedAt: Date.now(), lifetime: msg.lifetime, }, }); wsService.preventDisconnectOnBackground = true; processedCallIds.set(msg.call_id, Date.now()); // Report incoming call to system CallKit/Telecom UI callKeepService.reportIncomingCallEvent({ eventId: `${msg.call_id}-${Date.now()}`, serverCallId: msg.call_id, hasVideo: msg.call_type === 'video', startedAt: new Date(msg.created_at).toISOString(), caller: { id: msg.caller_id, displayName: callerName, avatarUrl: caller?.avatar || undefined, }, metadata: { conversationId: msg.conversation_id }, }); if (callTimeoutTimer) clearTimeout(callTimeoutTimer); const remainingTime = Math.max(lifetime - callAge - 1000, 5000); callTimeoutTimer = setTimeout(() => { const { incomingCall: ic } = get(); if (ic?.callId === msg.call_id) { console.log('[CallStore] Incoming call timeout'); wsService.preventDisconnectOnBackground = false; wsService.sendCallReject(msg.call_id); processedCallIds.set(msg.call_id, Date.now()); set({ incomingCall: null }); } }, remainingTime); }) ); unsubs.push( wsService.on('call_accepted', async (msg) => { const { currentCall } = get(); if (!currentCall || currentCall.id !== msg.call_id) return; if (!currentCall.isInitiator) { console.log('[CallStore] Ignoring call_accepted, we are not the initiator'); return; } if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } try { set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, status: 'connecting' } : null, })); const isVideoCall = currentCall.callType === 'video'; await joinLiveKitRoom(currentCall.id, isVideoCall); } catch (err) { console.error('[CallStore] call_accepted LiveKit join error:', err); get().endCall('connection_failed'); } }) ); unsubs.push( wsService.on('call_rejected', (msg) => { const { currentCall } = get(); if (currentCall?.id !== msg.call_id) return; console.log('[CallStore] Call rejected'); get().endCall('rejected'); }) ); unsubs.push( wsService.on('call_busy', (msg) => { const { currentCall } = get(); if (currentCall?.id !== msg.call_id) return; console.log('[CallStore] Call busy'); get().endCall('busy'); }) ); unsubs.push( wsService.on('call_ended', (msg) => { const { currentCall, incomingCall } = get(); processedCallIds.set(msg.call_id, Date.now()); if (currentCall?.id === msg.call_id) { console.log('[CallStore] Call ended, reason:', msg.reason, 'ended_by:', msg.ended_by, 'duration:', msg.duration); get().endCall('ended'); return; } if (incomingCall?.callId === msg.call_id) { console.log('[CallStore] Incoming call cancelled by caller'); wsService.preventDisconnectOnBackground = false; if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } set({ incomingCall: null }); } }) ); unsubs.push( wsService.on('call_answered_elsewhere', (msg) => { const { incomingCall } = get(); if (incomingCall?.callId === msg.call_id) { console.log('[CallStore] Call answered on another device'); wsService.preventDisconnectOnBackground = false; processedCallIds.set(msg.call_id, Date.now()); if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } set({ incomingCall: null }); } }) ); unsubs.push( wsService.on('error', (msg: WSErrorMessage) => { const { currentCall, incomingCall } = get(); console.log('[CallStore] Server error:', msg.code, msg.message); if (msg.code === 'callee_offline') { if (currentCall && currentCall.status === 'ringing') { console.log('[CallStore] Callee is offline'); get().endCall('callee_offline'); } } if (msg.code === 'call_already_answered') { if (incomingCall) { console.log('[CallStore] Call already answered on another device'); processedCallIds.set(incomingCall.callId, Date.now()); if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } set({ incomingCall: null }); } } }) ); unsubs.push( wsService.on('call_peer_muted', (msg) => { console.log('[CallStore] Peer muted:', msg.user_id, msg.muted); }) ); const cleanup = () => { cleanupResources(); if (unsubInvited) { unsubInvited(); unsubInvited = null; } unsubs.forEach((unsub) => unsub()); initCallUnsub = null; }; initCallUnsub = cleanup; return cleanup; }, startCall: async ( conversationId: string, calleeId: string, calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }, callType: CallType = 'voice' ) => { const { currentCall } = get(); if (currentCall && currentCall.status !== 'idle') { console.warn('[CallStore] Already in a call'); return; } const myUserId = getCurrentUserId(); if (!myUserId) { console.error('[CallStore] Not logged in'); return; } wsService.preventDisconnectOnBackground = true; const cachedCallee = useUserStore.getState().userCache[calleeId]; const callee = calleeInfo || cachedCallee; const calleeName = callee?.nickname || callee?.username || calleeId; const localUser = useUserStore.getState().userCache[myUserId]; const localName = localUser?.nickname || localUser?.username || myUserId; const initialParticipants = buildInitialParticipants(myUserId, localName, calleeId, calleeName, callee?.avatar); set({ currentCall: { id: '', conversationId, peerId: calleeId, peerName: calleeName, peerAvatar: callee?.avatar, callType, mediaType: callType, status: 'calling', duration: 0, isMuted: false, isSpeakerOn: callType === 'video', isVideoEnabled: callType === 'video', isPeerVideoEnabled: false, isInitiator: true, isPeerReady: false, participants: initialParticipants, activeSpeakerId: null, pinnedParticipantId: null, }, }); wsService.sendCallInvite(conversationId, calleeId, callType); callKeepService.startOutgoingCall(calleeId, calleeName, callType === 'video').then((systemCallId) => { if (systemCallId) { // Sync system call ID with store, but don't overwrite if WS already set it const { currentCall: cc } = get(); if (cc && !cc.id) { set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, id: systemCallId } : null, })); } } }); if (unsubInvited) { unsubInvited(); } unsubInvited = wsService.on('call_invited', (msg) => { set((s) => { if (!s.currentCall) return {}; // WS call_id is the authoritative one; sync system call ID too if (s.currentCall.id !== msg.call_id) { callKeepService.endActiveCall(); // end old system session with wrong ID } return { currentCall: { ...s.currentCall, id: msg.call_id } }; }); }); if (callTimeoutTimer) clearTimeout(callTimeoutTimer); callTimeoutTimer = setTimeout(() => { const { currentCall: cc } = get(); if (cc && cc.status === 'calling') { console.warn('[CallStore] Call timeout'); get().endCall('timeout'); } }, CALL_TIMEOUT_MS); }, acceptCall: async () => { const { incomingCall } = get(); if (!incomingCall) return; wsService.preventDisconnectOnBackground = true; console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType); if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } const isVideoCall = incomingCall.callType === 'video'; console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall); const myUserId = getCurrentUserId(); const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null; const localName = localUser?.nickname || localUser?.username || myUserId || 'me'; const participants = new Map(); const now = Date.now(); participants.set(myUserId || 'me', { id: myUserId || 'me', name: localName, avatar: null, isMuted: false, isVideoEnabled: false, isLocal: true, isReady: true, joinedAt: now, }); participants.set(incomingCall.callerId, { id: incomingCall.callerId, name: incomingCall.callerName || incomingCall.callerId, avatar: incomingCall.callerAvatar ?? null, isMuted: false, isVideoEnabled: false, isLocal: false, isReady: false, joinedAt: 0, }); set({ currentCall: { id: incomingCall.callId, conversationId: incomingCall.conversationId, peerId: incomingCall.callerId, peerName: incomingCall.callerName, peerAvatar: incomingCall.callerAvatar, callType: incomingCall.callType as CallType, mediaType: incomingCall.callType as CallType, status: 'connecting', duration: 0, isMuted: false, isSpeakerOn: isVideoCall, isVideoEnabled: isVideoCall, isPeerVideoEnabled: false, isInitiator: false, isPeerReady: false, participants, activeSpeakerId: null, pinnedParticipantId: null, }, incomingCall: null, }); wsService.sendCallAnswer(incomingCall.callId); try { await joinLiveKitRoom(incomingCall.callId, isVideoCall); } catch (err) { console.error('[CallStore] Failed to accept call:', err); callKeepService.failIncomingCallConnected(); get().endCall('connection_failed'); } }, rejectCall: () => { const { incomingCall } = get(); if (!incomingCall) return; wsService.preventDisconnectOnBackground = false; if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } wsService.sendCallReject(incomingCall.callId); callKeepService.endActiveCall(); set({ incomingCall: null }); }, endCall: async (reason = 'ended') => { const { currentCall } = get(); if (!currentCall) return; wsService.preventDisconnectOnBackground = false; cleanupResources(); if (unsubInvited) { unsubInvited(); unsubInvited = null; } const callId = currentCall.id; set({ currentCall: null, callDuration: 0, }); try { await liveKitService.disconnect(); } catch (err) { console.error('[CallStore] Error disconnecting LiveKit:', err); } // Sync end with system CallKit/Telecom UI if (reason === 'ended' || !reason) { callKeepService.endActiveCall(); } else { callKeepService.reportCallEndedExternal(reason as any); } if (callId && reason !== 'ended') { wsService.sendCallEnd(callId, reason); } }, toggleMute: () => { const { currentCall } = get(); if (!currentCall) return; const newMuted = !currentCall.isMuted; liveKitService.setMuted(newMuted); callKeepService.setMuted(newMuted); if (currentCall.id) { wsService.sendCallMute(currentCall.id, newMuted); } set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null, })); }, toggleSpeaker: async () => { const { currentCall } = get(); if (!currentCall) return; const newSpeakerOn = !currentCall.isSpeakerOn; set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isSpeakerOn: newSpeakerOn } : null, })); callKeepService.setAudioOutputToSpeaker(newSpeakerOn); await liveKitService.setSpeakerOn(newSpeakerOn); }, toggleMinimize: () => { set((s) => ({ isMinimized: !s.isMinimized })); }, toggleVideo: async () => { const { currentCall } = get(); if (!currentCall) return; const newVideoEnabled = !currentCall.isVideoEnabled; await get().setVideoEnabled(newVideoEnabled); }, setVideoEnabled: async (enabled: boolean) => { const { currentCall } = get(); if (!currentCall) return; try { await liveKitService.setVideoEnabled(enabled); callKeepService.reportVideoEnabled(enabled); set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' } : null, })); } catch (err) { console.error('[CallStore] Failed to toggle video:', err); } }, handleSystemAnswer: async () => { const { incomingCall, currentCall } = get(); // If already handling a call from the in-app flow, skip if (currentCall && currentCall.status !== 'idle') return; if (!incomingCall) return; wsService.preventDisconnectOnBackground = true; if (callTimeoutTimer) { clearTimeout(callTimeoutTimer); callTimeoutTimer = null; } const isVideoCall = incomingCall.callType === 'video'; const myUserId = getCurrentUserId(); const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null; const localName = localUser?.nickname || localUser?.username || myUserId || 'me'; const sysParticipants = new Map(); const sysNow = Date.now(); sysParticipants.set(myUserId || 'me', { id: myUserId || 'me', name: localName, avatar: null, isMuted: false, isVideoEnabled: false, isLocal: true, isReady: true, joinedAt: sysNow, }); sysParticipants.set(incomingCall.callerId, { id: incomingCall.callerId, name: incomingCall.callerName || incomingCall.callerId, avatar: incomingCall.callerAvatar ?? null, isMuted: false, isVideoEnabled: false, isLocal: false, isReady: false, joinedAt: 0, }); set({ currentCall: { id: incomingCall.callId, conversationId: incomingCall.conversationId, peerId: incomingCall.callerId, peerName: incomingCall.callerName, peerAvatar: incomingCall.callerAvatar, callType: incomingCall.callType as CallType, mediaType: incomingCall.callType as CallType, status: 'connecting', duration: 0, isMuted: false, isSpeakerOn: isVideoCall, isVideoEnabled: isVideoCall, isPeerVideoEnabled: false, isInitiator: false, isPeerReady: false, participants: sysParticipants, activeSpeakerId: null, pinnedParticipantId: null, }, incomingCall: null, }); wsService.sendCallAnswer(incomingCall.callId); try { await joinLiveKitRoom(incomingCall.callId, isVideoCall); } catch (err) { console.error('[CallStore] handleSystemAnswer failed:', err); callKeepService.failIncomingCallConnected(); get().endCall('connection_failed'); } }, handleIncomingFromPush: (session: SystemCallSession) => { const ie = session.incomingCallEvent; if (!ie) return; const { currentCall, incomingCall } = get(); if (currentCall || incomingCall) return; wsService.preventDisconnectOnBackground = true; const metadata = ie.metadata ?? {}; const conversationId = (metadata.conversationId as string) || ''; set({ incomingCall: { callId: ie.serverCallId, conversationId, callerId: ie.caller.id, callerName: ie.caller.displayName || ie.caller.id, callerAvatar: ie.caller.avatarUrl ?? null, callType: ie.hasVideo ? 'video' : 'voice', receivedAt: Date.now(), lifetime: 55000, }, }); if (callTimeoutTimer) clearTimeout(callTimeoutTimer); callTimeoutTimer = setTimeout(() => { const { incomingCall: ic } = get(); if (ic?.callId === ie.serverCallId) { console.log('[CallStore] Incoming push call timeout'); wsService.preventDisconnectOnBackground = false; callKeepService.endActiveCall(); set({ incomingCall: null }); } }, 55000); }, handleSystemMuted: (muted: boolean) => { const { currentCall } = get(); if (!currentCall) return; liveKitService.setMuted(muted); if (currentCall.id) { wsService.sendCallMute(currentCall.id, muted); } set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, isMuted: muted } : null, })); }, startGroupCall: async (groupId: string, conversationId: string, callType: CallType) => { const { currentCall } = get(); if (currentCall && currentCall.status !== 'idle') { console.warn('[CallStore] Already in a call'); return; } const myUserId = getCurrentUserId(); if (!myUserId) { console.error('[CallStore] Not logged in'); return; } wsService.preventDisconnectOnBackground = true; const localUser = useUserStore.getState().userCache[myUserId]; const localName = localUser?.nickname || localUser?.username || myUserId; const participants = new Map(); const now = Date.now(); participants.set(myUserId, { id: myUserId, name: localName, avatar: null, isMuted: false, isVideoEnabled: false, isLocal: true, isReady: true, joinedAt: now, }); set({ currentCall: { id: '', conversationId, groupId, peerId: '', peerName: undefined, peerAvatar: null, callType, mediaType: callType, status: 'calling', duration: 0, isMuted: false, isSpeakerOn: callType === 'video', isVideoEnabled: callType === 'video', isPeerVideoEnabled: false, isInitiator: true, isPeerReady: false, participants, activeSpeakerId: null, pinnedParticipantId: null, }, }); wsService.sendCallGroupInvite(groupId, conversationId, callType); if (unsubInvited) unsubInvited(); unsubInvited = wsService.on('call_invited', (msg: any) => { set((s) => { if (!s.currentCall) return {}; return { currentCall: { ...s.currentCall, id: msg.call_id } }; }); }); if (callTimeoutTimer) clearTimeout(callTimeoutTimer); callTimeoutTimer = setTimeout(() => { const { currentCall: cc } = get(); if (cc && cc.status === 'calling') { console.warn('[CallStore] Group call timeout'); get().endCall('timeout'); } }, CALL_TIMEOUT_MS); }, addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => { const { currentCall } = get(); if (!currentCall) return; const newParticipants = new Map(currentCall.participants); if (!newParticipants.has(userId)) { newParticipants.set(userId, { id: userId, name: info.name || userId, avatar: info.avatar ?? null, isMuted: false, isVideoEnabled: false, isLocal: false, isReady: true, joinedAt: Date.now(), }); } else { const existing = newParticipants.get(userId)!; newParticipants.set(userId, { ...existing, isReady: true, joinedAt: existing.joinedAt || Date.now(), }); } set({ currentCall: { ...currentCall, participants: newParticipants } }); }, removeParticipant: (userId: string) => { const { currentCall } = get(); if (!currentCall) return; const newParticipants = new Map(currentCall.participants); newParticipants.delete(userId); set({ currentCall: { ...currentCall, participants: newParticipants } }); }, setActiveSpeaker: (userId: string | null) => { set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, activeSpeakerId: userId } : null, })); }, pinParticipant: (userId: string | null) => { set((s) => ({ currentCall: s.currentCall ? { ...s.currentCall, pinnedParticipantId: userId } : null, })); }, updateParticipantState: (userId: string, updates: Partial) => { const { currentCall } = get(); if (!currentCall) return; const p = currentCall.participants.get(userId); if (!p) return; const newParticipants = new Map(currentCall.participants); newParticipants.set(userId, { ...p, ...updates }); set({ currentCall: { ...currentCall, participants: newParticipants } }); }, reset: () => { cleanupResources(); if (initCallUnsub) { initCallUnsub(); initCallUnsub = null; } if (unsubInvited) { unsubInvited(); unsubInvited = null; } liveKitService.dispose(); callKeepService.endActiveCall(); processedCallIds.clear(); set({ currentCall: null, incomingCall: null, callDuration: 0, isMinimized: false, }); }, }));