Show call support error and disable join button (#2748)

* allow user to end call if error when loading

* show call support missing error if livekit server is not provided

* prevent joining from nav item double click if no livekit support
This commit is contained in:
Ajay Bura
2026-03-09 21:39:58 +11:00
committed by GitHub
parent 2eb5a9a616
commit 4449e7c6e8
9 changed files with 220 additions and 62 deletions

View File

@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.identity_server'?: { 'm.identity_server'?: {
base_url: string; 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 ( export const autoDiscovery = async (

View File

@@ -1,14 +1,17 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { StatusDivider } from './components'; import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
type MicrophoneButtonProps = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => Promise<unknown>; onToggle: () => Promise<unknown>;
disabled?: boolean;
}; };
function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -27,6 +30,7 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
size="300" size="300"
onClick={() => onToggle()} onClick={() => onToggle()}
outlined outlined
disabled={disabled}
> >
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} /> <Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton> </IconButton>
@@ -38,8 +42,9 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
type SoundButtonProps = { type SoundButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;
disabled?: boolean;
}; };
function SoundButton({ enabled, onToggle }: SoundButtonProps) { function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -58,6 +63,7 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="300" size="300"
onClick={() => onToggle()} onClick={() => onToggle()}
outlined outlined
disabled={disabled}
> >
<Icon <Icon
size="100" size="100"
@@ -73,8 +79,9 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) {
type VideoButtonProps = { type VideoButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => Promise<unknown>; onToggle: () => Promise<unknown>;
disabled?: boolean;
}; };
function VideoButton({ enabled, onToggle }: VideoButtonProps) { function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -93,6 +100,7 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
size="300" size="300"
onClick={() => onToggle()} onClick={() => onToggle()}
outlined outlined
disabled={disabled}
> >
<Icon <Icon
size="100" size="100"
@@ -108,8 +116,9 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
type ScreenShareButtonProps = { type ScreenShareButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => void; onToggle: () => void;
disabled?: boolean;
}; };
function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) { function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return ( return (
<TooltipProvider <TooltipProvider
position="Top" position="Top"
@@ -128,6 +137,7 @@ function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
size="300" size="300"
onClick={onToggle} onClick={onToggle}
outlined outlined
disabled={disabled}
> >
<Icon size="100" src={Icons.ScreenShare} filled={enabled} /> <Icon size="100" src={Icons.ScreenShare} filled={enabled} />
</IconButton> </IconButton>
@@ -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 { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]) useCallback(() => callEmbed.hangup(), [callEmbed])
@@ -145,20 +164,38 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp
const exiting = const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
const handleHangup = () => {
if (!callJoined) {
setCallEmbed(undefined);
return;
}
hangup();
};
return ( return (
<Box shrink="No" alignItems="Center" gap="300"> <Box shrink="No" alignItems="Center" gap="300">
<Box alignItems="Inherit" gap="200"> <Box alignItems="Inherit" gap="200">
<MicrophoneButton <MicrophoneButton
enabled={microphone} enabled={microphone}
onToggle={() => callEmbed.control.toggleMicrophone()} onToggle={() => callEmbed.control.toggleMicrophone()}
disabled={!callJoined}
/>
<SoundButton
enabled={sound}
onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined}
/> />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
{!compact && <StatusDivider />} {!compact && <StatusDivider />}
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} /> <VideoButton
enabled={video}
onToggle={() => callEmbed.control.toggleVideo()}
disabled={!callJoined}
/>
{!compact && ( {!compact && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => callEmbed.control.toggleScreenshare()} onToggle={() => callEmbed.control.toggleScreenshare()}
disabled={!callJoined}
/> />
)} )}
</Box> </Box>
@@ -176,7 +213,7 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp
} }
disabled={exiting} disabled={exiting}
outlined outlined
onClick={hangup} onClick={handleHangup}
> >
{!compact && ( {!compact && (
<Text as="span" size="L400"> <Text as="span" size="L400">

View File

@@ -74,7 +74,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
<CallRoomName room={room} /> <CallRoomName room={room} />
</Box> </Box>
)} )}
<CallControl compact={compact} callEmbed={callEmbed} /> <CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
</Box> </Box>
</Box> </Box>
); );

View File

@@ -13,10 +13,29 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { CallMemberRenderer } from './CallMemberCard'; import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css'; import * as css from './styles.css';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
function JoinMessage({ hasParticipant }: { hasParticipant?: boolean }) { function LivekitServerMissingMessage() {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling. But you can still join call started by others.
</Text>
);
}
function JoinMessage({
hasParticipant,
livekitSupported,
}: {
hasParticipant?: boolean;
livekitSupported?: boolean;
}) {
if (hasParticipant) return null; if (hasParticipant) return null;
if (livekitSupported === false) {
return <LivekitServerMissingMessage />;
}
return ( return (
<Text style={{ margin: 'auto' }} size="L400" align="Center"> <Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in! Voice chats empty Be the first to hop in!
@@ -43,12 +62,13 @@ function AlreadyInCallMessage() {
function CallPrescreen() { function CallPrescreen() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const room = useRoom(); const room = useRoom();
const livekitSupported = useLivekitSupport();
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels); 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 callSession = useCallSession(room);
const callMembers = useCallMembers(room, callSession); const callMembers = useCallMembers(room, callSession);
@@ -57,6 +77,8 @@ function CallPrescreen() {
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && (livekitSupported || hasParticipant);
return ( return (
<Scroll variant="Surface" hideTrack> <Scroll variant="Surface" hideTrack>
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center"> <Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
@@ -77,7 +99,11 @@ function CallPrescreen() {
<PrescreenControls canJoin={canJoin} /> <PrescreenControls canJoin={canJoin} />
<Header size="300"> <Header size="300">
{!inOtherCall && {!inOtherCall &&
(canJoin ? <JoinMessage hasParticipant={hasParticipant} /> : <NoPermissionMessage />)} (hasPermission ? (
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
) : (
<NoPermissionMessage />
))}
{inOtherCall && <AlreadyInCallMessage />} {inOtherCall && <AlreadyInCallMessage />}
</Header> </Header>
</Box> </Box>

View File

@@ -57,6 +57,8 @@ import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@@ -282,8 +284,14 @@ export function RoomNavItem({
const startCall = useCallStart(direct); const startCall = useCallStart(direct);
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
const callPref = useAtomValue(useCallPreferencesAtom()); const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => { const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (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 // Do not join if already in call
if (callEmbed) { if (callEmbed) {
return; return;

View File

@@ -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);
};

View File

@@ -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 <AutoDiscoveryInfoProvider value={info ?? fallback}>{children}</AutoDiscoveryInfoProvider>;
}

View File

@@ -35,6 +35,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
function ClientRootLoading() { function ClientRootLoading() {
return ( return (
@@ -143,7 +144,7 @@ type ClientRootProps = {
}; };
export function ClientRoot({ children }: ClientRootProps) { export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { baseUrl } = getFallbackSession() ?? {}; const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>( const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
useCallback(() => { useCallback(() => {
@@ -183,47 +184,55 @@ export function ClientRoot({ children }: ClientRootProps) {
); );
return ( return (
<SpecVersions baseUrl={baseUrl!}> <AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />} <SpecVersions baseUrl={baseUrl!}>
{loading && <ClientRootOptions mx={mx} />} {mx && <SyncStatus mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( {loading && <ClientRootOptions mx={mx} />}
<SplashScreen> {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400"> <SplashScreen>
<Dialog> <Box
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}> direction="Column"
{loadState.status === AsyncStatus.Error && ( grow="Yes"
<Text>{`Failed to load. ${loadState.error.message}`}</Text> alignItems="Center"
)} justifyContent="Center"
{startState.status === AsyncStatus.Error && ( gap="400"
<Text>{`Failed to start. ${startState.error.message}`}</Text> >
)} <Dialog>
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}> <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text as="span" size="B400"> {loadState.status === AsyncStatus.Error && (
Retry <Text>{`Failed to load. ${loadState.error.message}`}</Text>
</Text> )}
</Button> {startState.status === AsyncStatus.Error && (
</Box> <Text>{`Failed to start. ${startState.error.message}`}</Text>
</Dialog> )}
</Box> <Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
</SplashScreen> <Text as="span" size="B400">
)} Retry
{loading || !mx ? ( </Text>
<ClientRootLoading /> </Button>
) : ( </Box>
<MatrixClientProvider value={mx}> </Dialog>
<ServerConfigsLoader> </Box>
{(serverConfigs) => ( </SplashScreen>
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}> )}
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}> {loading || !mx ? (
<AuthMetadataProvider value={serverConfigs.authMetadata}> <ClientRootLoading />
{children} ) : (
</AuthMetadataProvider> <MatrixClientProvider value={mx}>
</MediaConfigProvider> <ServerConfigsLoader>
</CapabilitiesProvider> {(serverConfigs) => (
)} <CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
</ServerConfigsLoader> <MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
</MatrixClientProvider> <AuthMetadataProvider value={serverConfigs.authMetadata}>
)} {children}
</SpecVersions> </AuthMetadataProvider>
</MediaConfigProvider>
</CapabilitiesProvider>
)}
</ServerConfigsLoader>
</MatrixClientProvider>
)}
</SpecVersions>
</AutoDiscovery>
); );
} }

View File

@@ -146,7 +146,7 @@ export class CallEmbed {
let initialMediaEvent = true; let initialMediaEvent = true;
this.disposables.push( this.disposables.push(
this.listenEvent<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => { this.listenAction<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
if (initialMediaEvent) { if (initialMediaEvent) {
initialMediaEvent = false; initialMediaEvent = false;
this.control.applyState(); this.control.applyState();
@@ -177,18 +177,27 @@ export class CallEmbed {
return this.call.transport.send(ElementWidgetActions.HangupCall, {}); return this.call.transport.send(ElementWidgetActions.HangupCall, {});
} }
public listenEvent<T>(type: string, callback: (event: CustomEvent<T>) => void) { public onPreparing(callback: () => void) {
this.call.on(`action:${type}`, callback); return this.listenEvent('preparing', callback);
return () => { }
this.call.off(`action:${type}`, 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() { private start() {
// Room widgets get locked to the room they were added in // Room widgets get locked to the room they were added in
this.call.setViewedRoomId(this.roomId); this.call.setViewedRoomId(this.roomId);
this.disposables.push( 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. // 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<T>(type: string, callback: (event: CustomEvent<T>) => void) {
return this.listenEvent(`action:${type}`, callback);
}
public listenEvent<T>(type: string, callback: (event: T) => void) {
this.call.on(type, callback);
return () => {
this.call.off(type, callback);
};
}
} }