- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
1268 lines
36 KiB
TypeScript
1268 lines
36 KiB
TypeScript
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<string, CallParticipant>;
|
|
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<void>;
|
|
acceptCall: () => Promise<void>;
|
|
rejectCall: () => void;
|
|
endCall: (reason?: string) => Promise<void>;
|
|
toggleMute: () => void;
|
|
toggleSpeaker: () => void;
|
|
toggleMinimize: () => void;
|
|
toggleVideo: () => Promise<void>;
|
|
setVideoEnabled: (enabled: boolean) => Promise<void>;
|
|
handleSystemAnswer: () => Promise<void>;
|
|
handleIncomingFromPush: (session: SystemCallSession) => void;
|
|
handleSystemMuted: (muted: boolean) => void;
|
|
startGroupCall: (
|
|
groupId: string,
|
|
conversationId: string,
|
|
callType: CallType
|
|
) => Promise<void>;
|
|
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<CallParticipant>) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
// Module-level variables for timers
|
|
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
|
let callTimeoutTimer: ReturnType<typeof setTimeout> | 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<string, number>();
|
|
|
|
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<string, CallParticipant> {
|
|
const m = new Map<string, CallParticipant>();
|
|
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<void> {
|
|
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<CallState>((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<string, CallParticipant>();
|
|
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<string, CallParticipant>();
|
|
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<string, CallParticipant>();
|
|
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<CallParticipant>) => {
|
|
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,
|
|
});
|
|
},
|
|
}));
|