Files
cinny/src/app/features/call/useCall.ts
litruv 30edd62d96 Make room switches instant and cut redundant client work.
Keep-alive room cache with CSS-only warm switches, nav loading feedback, and per-context members drawer prefs. Share hierarchy/call/sub-room subscriptions, demux state events, and lazy lobby power-level loading to avoid duplicate scans and listener storms.
2026-07-22 18:00:54 +10:00

348 lines
10 KiB
TypeScript

import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { CallService } from './CallService';
import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types';
import { fetchWellKnownWithRTC, getLiveKitFocus, getLiveKitHomeserverPriority } from './useRtcConfig';
interface CallContextValue {
callService: CallService | null;
activeCall: ActiveCall | null;
callSupported: boolean;
callSupportLoading: boolean;
startCall: (roomId: string, callType: CallType) => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => boolean;
toggleDeafen: () => boolean;
toggleVideo: () => boolean;
toggleScreenShare: () => Promise<boolean>;
}
const CallContext = createContext<CallContextValue | null>(null);
/**
* Global reference to CallService for use outside React components
* (e.g., from Electron IPC handlers)
*/
let globalCallServiceInstance: CallService | null = null;
/**
* Get the global CallService instance
* @returns The CallService instance or null if not initialized
*/
export function getCallService(): CallService | null {
return globalCallServiceInstance;
}
/**
* Set the global CallService instance
* @param service - The CallService instance
*/
function setGlobalCallService(service: CallService | null): void {
globalCallServiceInstance = service;
}
/**
* Hook to access the call context
* @returns The call context value
*/
export function useCall(): CallContextValue {
const context = useContext(CallContext);
if (!context) {
throw new Error('useCall must be used within a CallProvider');
}
return context;
}
export const CallProvider = CallContext.Provider;
/**
* Hook to initialize and manage the call service
* Should be used at the app level to provide call context
*/
export function useCallService(): CallContextValue {
const mx = useMatrixClient();
const [callService, setCallService] = useState<CallService | null>(null);
const [activeCall, setActiveCall] = useState<ActiveCall | null>(null);
const [callSupported, setCallSupported] = useState(false);
const [callSupportLoading, setCallSupportLoading] = useState(true);
useEffect(() => {
let unsubscribe: (() => void) | undefined;
let service: CallService | undefined;
const initCallService = async () => {
const baseUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getSafeUserId();
const deviceId = mx.getDeviceId();
if (!accessToken || !deviceId) {
setCallSupportLoading(false);
return;
}
try {
// Initialize CallService without checking user's homeserver
// Call support will be determined per-room based on available homeservers
console.log('Call service init - Initializing (call support determined per-room)');
service = new CallService(
{
homeserverBaseUrl: baseUrl,
accessToken,
userId,
deviceId,
},
mx
);
setCallService(service);
setGlobalCallService(service); // Make available globally for Paarrot API
setCallSupported(true); // Global flag - actual support checked per-room
setCallSupportLoading(false);
// eslint-disable-next-line no-console
console.log('Call service init - Success!');
unsubscribe = service.on((event: CallEvent) => {
// Update state for any event that might change the call state
if (event.type === CallEventType.StateChanged ||
event.type === CallEventType.ScreenShareStarted ||
event.type === CallEventType.ScreenShareStopped ||
event.type === CallEventType.RemoteStreamAdded ||
event.type === CallEventType.RemoteStreamRemoved ||
event.type === CallEventType.ParticipantJoined ||
event.type === CallEventType.ParticipantLeft ||
event.type === CallEventType.SpeakersChanged) {
const newCall = service?.getActiveCall();
if (newCall) {
// Deep copy Maps and Sets to ensure React detects changes
setActiveCall({
...newCall,
remoteStreams: new Map(newCall.remoteStreams),
remoteScreenTracks: new Map(newCall.remoteScreenTracks),
remoteScreenStreams: new Map(newCall.remoteScreenStreams),
activeSpeakers: new Set(newCall.activeSpeakers),
});
} else {
setActiveCall(null);
}
}
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Call service init - Error:', error);
setCallSupported(false);
setCallSupportLoading(false);
}
};
initCallService();
return () => {
unsubscribe?.();
service?.dispose();
setGlobalCallService(null); // Clear global reference
};
}, [mx]);
const startCall = useCallback(
async (roomId: string, callType: CallType) => {
if (!callService) {
throw new Error('Call service not initialized');
}
await callService.startCall(roomId, callType);
},
[callService]
);
const endCall = useCallback(async () => {
if (!callService) {
return;
}
await callService.endCall();
}, [callService]);
const toggleMute = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleMute();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleDeafen = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleDeafen();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleVideo = useCallback(() => {
if (!callService) {
return false;
}
const newState = callService.toggleVideo();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
const toggleScreenShare = useCallback(async () => {
if (!callService) {
return false;
}
const newState = await callService.toggleScreenShare();
const call = callService.getActiveCall();
if (call) {
setActiveCall({ ...call });
}
return newState;
}, [callService]);
return useMemo(
() => ({
callService,
activeCall,
callSupported,
callSupportLoading,
startCall,
endCall,
toggleMute,
toggleDeafen,
toggleVideo,
toggleScreenShare,
}),
[callService, activeCall, callSupported, callSupportLoading, startCall, endCall, toggleMute, toggleDeafen, toggleVideo, toggleScreenShare]
);
}
/**
* Hook to check if a call is active in a specific room
*/
/** Persists across keep-alive remounts so room switches don't re-hit well-known. */
const roomCallSupportCache = new Map<string, boolean>();
export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
const mx = useMatrixClient();
const [roomCallSupported, setRoomCallSupported] = useState(
() => roomCallSupportCache.get(roomId) ?? false
);
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(
() => !roomCallSupportCache.has(roomId)
);
// Check if calls are supported for this specific room by checking all priority homeservers
useEffect(() => {
const cached = roomCallSupportCache.get(roomId);
if (cached !== undefined) {
setRoomCallSupported(cached);
setRoomCallSupportLoading(false);
return undefined;
}
let cancelled = false;
const checkRoomCallSupport = async () => {
setRoomCallSupportLoading(true);
const room = mx.getRoom(roomId);
if (!room) {
if (!cancelled) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
}
return;
}
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
for (const homeserver of homeserverPriority) {
try {
const wellKnown = await fetchWellKnownWithRTC(homeserver);
const livekitFocus = getLiveKitFocus(wellKnown);
if (livekitFocus) {
roomCallSupportCache.set(roomId, true);
if (!cancelled) {
setRoomCallSupported(true);
setRoomCallSupportLoading(false);
}
return;
}
} catch {
// Continue to next homeserver
}
}
roomCallSupportCache.set(roomId, false);
if (!cancelled) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
}
};
checkRoomCallSupport();
return () => {
cancelled = true;
};
}, [roomId, mx]);
const isInCall = activeCall?.roomId === roomId;
const callState = isInCall ? activeCall.state : CallState.Idle;
const isUsingRemoteHomeserver = isInCall ? callService?.isUsingRemoteHomeserver() ?? false : false;
// Check if a remote homeserver WOULD be used (pre-call check)
const wouldUseRemoteHomeserver = useMemo(() => {
if (isInCall) return isUsingRemoteHomeserver;
const room = mx.getRoom(roomId);
if (!room) return false;
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
// Check if first priority is different from user's homeserver
const userHomeserverDomain = mx.getHomeserverUrl()
.replace('https://', '')
.replace('http://', '');
const firstPriorityDomain = homeserverPriority[0]
?.replace('https://', '')
.replace('http://', '');
return firstPriorityDomain !== userHomeserverDomain;
}, [isInCall, isUsingRemoteHomeserver, mx, roomId]);
const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]);
const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]);
return {
isInCall,
callState,
activeCall: isInCall ? activeCall : null,
isUsingRemoteHomeserver: wouldUseRemoteHomeserver,
startVoiceCall,
startVideoCall,
endCall,
callSupported: roomCallSupported,
callSupportLoading: roomCallSupportLoading,
};
}