diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts index b9aac06a..95a131a8 100644 --- a/src/app/cs-api.ts +++ b/src/app/cs-api.ts @@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record & { 'm.identity_server'?: { base_url: string; }; + 'org.matrix.msc2965.authentication'?: { + account?: string; + issuer?: string; + }; + 'org.matrix.msc4143.rtc_foci'?: [ + { + livekit_service_url: string; + type: 'livekit'; + } + ]; }; export const autoDiscovery = async ( diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 2f2bac7f..6416fda5 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -1,14 +1,17 @@ import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; import React, { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { callEmbedAtom } from '../../state/callEmbed'; type MicrophoneButtonProps = { enabled: boolean; onToggle: () => Promise; + disabled?: boolean; }; -function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { +function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { return ( onToggle()} outlined + disabled={disabled} > @@ -38,8 +42,9 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { type SoundButtonProps = { enabled: boolean; onToggle: () => void; + disabled?: boolean; }; -function SoundButton({ enabled, onToggle }: SoundButtonProps) { +function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) { return ( onToggle()} outlined + disabled={disabled} > Promise; + disabled?: boolean; }; -function VideoButton({ enabled, onToggle }: VideoButtonProps) { +function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) { return ( onToggle()} outlined + disabled={disabled} > void; + disabled?: boolean; }; -function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { +function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) { return ( @@ -136,8 +146,17 @@ function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { ); } -export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; compact: boolean }) { +export function CallControl({ + callEmbed, + compact, + callJoined, +}: { + callEmbed: CallEmbed; + compact: boolean; + callJoined: boolean; +}) { const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); + const setCallEmbed = useSetAtom(callEmbedAtom); const [hangupState, hangup] = useAsyncCallback( useCallback(() => callEmbed.hangup(), [callEmbed]) @@ -145,20 +164,38 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp const exiting = hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; + const handleHangup = () => { + if (!callJoined) { + setCallEmbed(undefined); + return; + } + hangup(); + }; + return ( callEmbed.control.toggleMicrophone()} + disabled={!callJoined} + /> + callEmbed.control.toggleSound()} + disabled={!callJoined} /> - callEmbed.control.toggleSound()} /> {!compact && } - callEmbed.control.toggleVideo()} /> + callEmbed.control.toggleVideo()} + disabled={!callJoined} + /> {!compact && ( callEmbed.control.toggleScreenshare()} + disabled={!callJoined} /> )} @@ -176,7 +213,7 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp } disabled={exiting} outlined - onClick={hangup} + onClick={handleHangup} > {!compact && ( diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 5d2182c2..1d30d1b4 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -74,7 +74,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) { )} - + ); diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 0cddd2be..7a8c28a5 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -13,10 +13,29 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { CallMemberRenderer } from './CallMemberCard'; import * as css from './styles.css'; import { CallControls } from './CallControls'; +import { useLivekitSupport } from '../../hooks/useLivekitSupport'; -function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { +function LivekitServerMissingMessage() { + return ( + + Your homeserver does not support calling. But you can still join call started by others. + + ); +} + +function JoinMessage({ + hasParticipant, + livekitSupported, +}: { + hasParticipant?: boolean; + livekitSupported?: boolean; +}) { if (hasParticipant) return null; + if (livekitSupported === false) { + return ; + } + return ( Voice chat’s empty — Be the first to hop in! @@ -43,12 +62,13 @@ function AlreadyInCallMessage() { function CallPrescreen() { const mx = useMatrixClient(); const room = useRoom(); + const livekitSupported = useLivekitSupport(); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); - const canJoin = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId()); + const hasPermission = permissions.event(StateEvent.GroupCallMemberPrefix, mx.getSafeUserId()); const callSession = useCallSession(room); const callMembers = useCallMembers(room, callSession); @@ -57,6 +77,8 @@ function CallPrescreen() { const callEmbed = useCallEmbed(); const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; + const canJoin = hasPermission && (livekitSupported || hasParticipant); + return ( @@ -77,7 +99,11 @@ function CallPrescreen() {
{!inOtherCall && - (canJoin ? : )} + (hasPermission ? ( + + ) : ( + + ))} {inOtherCall && }
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 9c5af938..b317b13a 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -57,6 +57,8 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { callChatAtom } from '../../state/callEmbed'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; +import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import { livekitSupport } from '../../hooks/useLivekitSupport'; type RoomNavItemMenuProps = { room: Room; @@ -282,8 +284,14 @@ export function RoomNavItem({ const startCall = useCallStart(direct); const callEmbed = useCallEmbed(); const callPref = useAtomValue(useCallPreferencesAtom()); + const autoDiscoveryInfo = useAutoDiscoveryInfo(); const handleStartCall: MouseEventHandler = (evt) => { + // Do not join if no livekit support or call is not started by others + if (!livekitSupport(autoDiscoveryInfo) && callMembers.length === 0) { + return; + } + // Do not join if already in call if (callEmbed) { return; diff --git a/src/app/hooks/useLivekitSupport.ts b/src/app/hooks/useLivekitSupport.ts new file mode 100644 index 00000000..3cb2c1d8 --- /dev/null +++ b/src/app/hooks/useLivekitSupport.ts @@ -0,0 +1,16 @@ +import { AutoDiscoveryInfo } from '../cs-api'; +import { useAutoDiscoveryInfo } from './useAutoDiscoveryInfo'; + +export const livekitSupport = (autoDiscoveryInfo: AutoDiscoveryInfo): boolean => { + const rtcFoci = autoDiscoveryInfo['org.matrix.msc4143.rtc_foci']; + + return ( + Array.isArray(rtcFoci) && rtcFoci.some((info) => typeof info.livekit_service_url === 'string') + ); +}; + +export const useLivekitSupport = (): boolean => { + const autoDiscoveryInfo = useAutoDiscoveryInfo(); + + return livekitSupport(autoDiscoveryInfo); +}; diff --git a/src/app/pages/client/AutoDiscovery.tsx b/src/app/pages/client/AutoDiscovery.tsx new file mode 100644 index 00000000..76423477 --- /dev/null +++ b/src/app/pages/client/AutoDiscovery.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode, useCallback, useMemo } from 'react'; +import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; +import { AsyncStatus, useAsyncCallbackValue } from '../../hooks/useAsyncCallback'; +import { autoDiscovery, AutoDiscoveryInfo } from '../../cs-api'; +import { getMxIdServer } from '../../utils/matrix'; + +type AutoDiscoveryProps = { + userId: string; + baseUrl: string; + children: ReactNode; +}; +export function AutoDiscovery({ userId, baseUrl, children }: AutoDiscoveryProps) { + const [state] = useAsyncCallbackValue( + useCallback(async () => { + const server = getMxIdServer(userId); + return autoDiscovery(fetch, server ?? userId); + }, [userId]) + ); + + const [, info] = state.status === AsyncStatus.Success ? state.data : []; + + const fallback: AutoDiscoveryInfo = useMemo( + () => ({ + 'm.homeserver': { + base_url: baseUrl, + }, + }), + [baseUrl] + ); + + return {children}; +} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index e1a5dc0c..93f0526e 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -35,6 +35,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; +import { AutoDiscovery } from './AutoDiscovery'; function ClientRootLoading() { return ( @@ -143,7 +144,7 @@ type ClientRootProps = { }; export function ClientRoot({ children }: ClientRootProps) { const [loading, setLoading] = useState(true); - const { baseUrl } = getFallbackSession() ?? {}; + const { baseUrl, userId } = getFallbackSession() ?? {}; const [loadState, loadMatrix] = useAsyncCallback( useCallback(() => { @@ -183,47 +184,55 @@ export function ClientRoot({ children }: ClientRootProps) { ); return ( - - {mx && } - {loading && } - {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( - - - - - {loadState.status === AsyncStatus.Error && ( - {`Failed to load. ${loadState.error.message}`} - )} - {startState.status === AsyncStatus.Error && ( - {`Failed to start. ${startState.error.message}`} - )} - - - - - - )} - {loading || !mx ? ( - - ) : ( - - - {(serverConfigs) => ( - - - - {children} - - - - )} - - - )} - + + + {mx && } + {loading && } + {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( + + + + + {loadState.status === AsyncStatus.Error && ( + {`Failed to load. ${loadState.error.message}`} + )} + {startState.status === AsyncStatus.Error && ( + {`Failed to start. ${startState.error.message}`} + )} + + + + + + )} + {loading || !mx ? ( + + ) : ( + + + {(serverConfigs) => ( + + + + {children} + + + + )} + + + )} + + ); } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index aeb28e36..87046676 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -146,7 +146,7 @@ export class CallEmbed { let initialMediaEvent = true; this.disposables.push( - this.listenEvent(ElementWidgetActions.DeviceMute, (evt) => { + this.listenAction(ElementWidgetActions.DeviceMute, (evt) => { if (initialMediaEvent) { initialMediaEvent = false; this.control.applyState(); @@ -177,18 +177,27 @@ export class CallEmbed { return this.call.transport.send(ElementWidgetActions.HangupCall, {}); } - public listenEvent(type: string, callback: (event: CustomEvent) => void) { - this.call.on(`action:${type}`, callback); - return () => { - this.call.off(`action:${type}`, callback); - }; + public onPreparing(callback: () => void) { + return this.listenEvent('preparing', callback); + } + + public onPreparingError(callback: (error: any) => void) { + return this.listenEvent('error:preparing', callback); + } + + public onReady(callback: () => void) { + return this.listenEvent('ready', callback); + } + + public onCapabilitiesNotified(callback: () => void) { + return this.listenEvent('capabilitiesNotified', callback); } private start() { // Room widgets get locked to the room they were added in this.call.setViewedRoomId(this.roomId); this.disposables.push( - this.listenEvent(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) + this.listenAction(ElementWidgetActions.JoinCall, this.onCallJoined.bind(this)) ); // Populate the map of "read up to" events for this widget with the current event in every room. @@ -375,4 +384,15 @@ export class CallEmbed { } } } + + public listenAction(type: string, callback: (event: CustomEvent) => void) { + return this.listenEvent(`action:${type}`, callback); + } + + public listenEvent(type: string, callback: (event: T) => void) { + this.call.on(type, callback); + return () => { + this.call.off(type, callback); + }; + } }