forked from github/cinny
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:
@@ -20,6 +20,16 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||
'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 (
|
||||
|
||||
@@ -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<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
@@ -27,6 +30,7 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||
</IconButton>
|
||||
@@ -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 (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
@@ -58,6 +63,7 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
@@ -73,8 +79,9 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
type VideoButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||
function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
@@ -93,6 +100,7 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||
size="300"
|
||||
onClick={() => onToggle()}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon
|
||||
size="100"
|
||||
@@ -108,8 +116,9 @@ function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||
type ScreenShareButtonProps = {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
|
||||
function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
@@ -128,6 +137,7 @@ function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) {
|
||||
size="300"
|
||||
onClick={onToggle}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon size="100" src={Icons.ScreenShare} filled={enabled} />
|
||||
</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 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 (
|
||||
<Box shrink="No" alignItems="Center" gap="300">
|
||||
<Box alignItems="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={() => callEmbed.control.toggleMicrophone()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
<SoundButton
|
||||
enabled={sound}
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
{!compact && <StatusDivider />}
|
||||
<VideoButton enabled={video} onToggle={() => callEmbed.control.toggleVideo()} />
|
||||
<VideoButton
|
||||
enabled={video}
|
||||
onToggle={() => callEmbed.control.toggleVideo()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => callEmbed.control.toggleScreenshare()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -176,7 +213,7 @@ export function CallControl({ callEmbed, compact }: { callEmbed: CallEmbed; comp
|
||||
}
|
||||
disabled={exiting}
|
||||
outlined
|
||||
onClick={hangup}
|
||||
onClick={handleHangup}
|
||||
>
|
||||
{!compact && (
|
||||
<Text as="span" size="L400">
|
||||
|
||||
@@ -74,7 +74,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
<CallRoomName room={room} />
|
||||
</Box>
|
||||
)}
|
||||
<CallControl compact={compact} callEmbed={callEmbed} />
|
||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<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 (livekitSupported === false) {
|
||||
return <LivekitServerMissingMessage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||
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 (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
|
||||
@@ -77,7 +99,11 @@ function CallPrescreen() {
|
||||
<PrescreenControls canJoin={canJoin} />
|
||||
<Header size="300">
|
||||
{!inOtherCall &&
|
||||
(canJoin ? <JoinMessage hasParticipant={hasParticipant} /> : <NoPermissionMessage />)}
|
||||
(hasPermission ? (
|
||||
<JoinMessage hasParticipant={hasParticipant} livekitSupported={livekitSupported} />
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
{inOtherCall && <AlreadyInCallMessage />}
|
||||
</Header>
|
||||
</Box>
|
||||
|
||||
@@ -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<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
|
||||
if (callEmbed) {
|
||||
return;
|
||||
|
||||
16
src/app/hooks/useLivekitSupport.ts
Normal file
16
src/app/hooks/useLivekitSupport.ts
Normal 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);
|
||||
};
|
||||
32
src/app/pages/client/AutoDiscovery.tsx
Normal file
32
src/app/pages/client/AutoDiscovery.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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<MatrixClient, Error, []>(
|
||||
useCallback(() => {
|
||||
@@ -183,47 +184,55 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<SpecVersions baseUrl={baseUrl!}>
|
||||
{mx && <SyncStatus mx={mx} />}
|
||||
{loading && <ClientRootOptions mx={mx} />}
|
||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||
<SplashScreen>
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
||||
<Dialog>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{loadState.status === AsyncStatus.Error && (
|
||||
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
|
||||
)}
|
||||
{startState.status === AsyncStatus.Error && (
|
||||
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
||||
)}
|
||||
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
||||
<Text as="span" size="B400">
|
||||
Retry
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</SplashScreen>
|
||||
)}
|
||||
{loading || !mx ? (
|
||||
<ClientRootLoading />
|
||||
) : (
|
||||
<MatrixClientProvider value={mx}>
|
||||
<ServerConfigsLoader>
|
||||
{(serverConfigs) => (
|
||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||
{children}
|
||||
</AuthMetadataProvider>
|
||||
</MediaConfigProvider>
|
||||
</CapabilitiesProvider>
|
||||
)}
|
||||
</ServerConfigsLoader>
|
||||
</MatrixClientProvider>
|
||||
)}
|
||||
</SpecVersions>
|
||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||
<SpecVersions baseUrl={baseUrl!}>
|
||||
{mx && <SyncStatus mx={mx} />}
|
||||
{loading && <ClientRootOptions mx={mx} />}
|
||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||
<SplashScreen>
|
||||
<Box
|
||||
direction="Column"
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
>
|
||||
<Dialog>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
{loadState.status === AsyncStatus.Error && (
|
||||
<Text>{`Failed to load. ${loadState.error.message}`}</Text>
|
||||
)}
|
||||
{startState.status === AsyncStatus.Error && (
|
||||
<Text>{`Failed to start. ${startState.error.message}`}</Text>
|
||||
)}
|
||||
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
|
||||
<Text as="span" size="B400">
|
||||
Retry
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</SplashScreen>
|
||||
)}
|
||||
{loading || !mx ? (
|
||||
<ClientRootLoading />
|
||||
) : (
|
||||
<MatrixClientProvider value={mx}>
|
||||
<ServerConfigsLoader>
|
||||
{(serverConfigs) => (
|
||||
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||
{children}
|
||||
</AuthMetadataProvider>
|
||||
</MediaConfigProvider>
|
||||
</CapabilitiesProvider>
|
||||
)}
|
||||
</ServerConfigsLoader>
|
||||
</MatrixClientProvider>
|
||||
)}
|
||||
</SpecVersions>
|
||||
</AutoDiscovery>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class CallEmbed {
|
||||
|
||||
let initialMediaEvent = true;
|
||||
this.disposables.push(
|
||||
this.listenEvent<ElementMediaStateDetail>(ElementWidgetActions.DeviceMute, (evt) => {
|
||||
this.listenAction<ElementMediaStateDetail>(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<T>(type: string, callback: (event: CustomEvent<T>) => 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<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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user