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.
348 lines
10 KiB
TypeScript
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,
|
|
};
|
|
}
|