0394fce929
- CallEmbed: 25s load watchdog that fails fast on iframe error / preparing-error / timeout instead of hanging on a permanent spinner; additive onLoadError API, cleared on ready/capabilities/joined. - CallView: user-visible "call failed to load" overlay with Retry/Leave (folds + tokens) via a new useCallLoadError hook. - CallMemberCard: wrap the participant avatar in AvatarDecoration so decorations render in the call roster (the tile rendered UserAvatar bare while member lists already wrapped it). Addresses LOTUS_BUGS item 3 (avatar decorations in calls) and EC iframe failure monitoring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
8.0 KiB
TypeScript
236 lines
8.0 KiB
TypeScript
import React, { RefObject, useRef } from 'react';
|
||
import { useSetAtom } from 'jotai';
|
||
import { Badge, Box, Button, color, Header, Icon, Icons, Scroll, Text, toRem } from 'folds';
|
||
import {
|
||
useCallEmbed,
|
||
useCallJoined,
|
||
useCallEmbedPlacementSync,
|
||
useCallLoadError,
|
||
} from '../../hooks/useCallEmbed';
|
||
import { callEmbedAtom } from '../../state/callEmbed';
|
||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||
import { PrescreenControls } from './PrescreenControls';
|
||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||
import { useRoom } from '../../hooks/useRoom';
|
||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||
import { StateEvent } from '../../../types/matrix/room';
|
||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||
import { CallMemberRenderer } from './CallMemberCard';
|
||
import * as css from './styles.css';
|
||
import { CallControls } from './CallControls';
|
||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||
import { webRTCSupported } from '../../utils/rtc';
|
||
|
||
function LivekitServerMissingMessage() {
|
||
return (
|
||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||
Your homeserver does not support calling.
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function WebRTCMissingError() {
|
||
return (
|
||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||
Your browser does not support WebRTC, which is required for calling.
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function JoinMessage({
|
||
hasParticipant,
|
||
livekitSupported,
|
||
rtcSupported,
|
||
}: {
|
||
hasParticipant?: boolean;
|
||
livekitSupported?: boolean;
|
||
rtcSupported?: boolean;
|
||
}) {
|
||
if (rtcSupported === false) {
|
||
return <WebRTCMissingError />;
|
||
}
|
||
|
||
if (livekitSupported === false) {
|
||
return <LivekitServerMissingMessage />;
|
||
}
|
||
|
||
if (hasParticipant) return null;
|
||
|
||
return (
|
||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||
Voice chat’s empty — Be the first to hop in!
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function NoPermissionMessage() {
|
||
return (
|
||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||
You don't have permission to join!
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function AlreadyInCallMessage() {
|
||
return (
|
||
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
|
||
Already in another call — End the current call to join!
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function ChannelFullMessage({ current, max }: { current: number; max: number }) {
|
||
return (
|
||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||
Channel Full ({current}/{max}) — Wait for someone to leave before joining.
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
function CallPrescreen() {
|
||
const mx = useMatrixClient();
|
||
const room = useRoom();
|
||
const livekitSupported = useLivekitSupport();
|
||
const rtcSupported = webRTCSupported();
|
||
|
||
const powerLevels = usePowerLevelsContext();
|
||
const creators = useRoomCreators(room);
|
||
|
||
const permissions = useRoomPermissions(creators, powerLevels);
|
||
const hasPermission = permissions.stateEvent(
|
||
StateEvent.GroupCallMemberPrefix,
|
||
mx.getSafeUserId(),
|
||
);
|
||
|
||
const callSession = useCallSession(room);
|
||
const callMembers = useCallMembers(callSession);
|
||
const hasParticipant = callMembers.length > 0;
|
||
|
||
const callEmbed = useCallEmbed();
|
||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||
|
||
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
|
||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||
// A user already counted in the session is rejoining and should not be blocked.
|
||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||
|
||
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||
|
||
return (
|
||
<Scroll variant="Surface" hideTrack>
|
||
<Box className={css.CallViewContent} alignItems="Center" justifyContent="Center">
|
||
<Box style={{ maxWidth: toRem(382), width: '100%' }} direction="Column" gap="100">
|
||
{hasParticipant && (
|
||
<Header size="300">
|
||
<Box grow="Yes" alignItems="Center">
|
||
<Text size="L400">Participant</Text>
|
||
</Box>
|
||
<Badge variant="Critical" fill="Solid" size="400">
|
||
<Text as="span" size="L400" truncate>
|
||
{callMembers.length} Live
|
||
</Text>
|
||
</Badge>
|
||
</Header>
|
||
)}
|
||
<CallMemberRenderer members={callMembers} />
|
||
<PrescreenControls canJoin={canJoin} />
|
||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||
{!inOtherCall && hasPermission && channelFull && (
|
||
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||
)}
|
||
{!inOtherCall && hasPermission && !channelFull && (
|
||
<JoinMessage
|
||
hasParticipant={hasParticipant}
|
||
livekitSupported={livekitSupported}
|
||
rtcSupported={rtcSupported}
|
||
/>
|
||
)}
|
||
{inOtherCall && <AlreadyInCallMessage />}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
</Scroll>
|
||
);
|
||
}
|
||
|
||
function CallLoadErrorMessage() {
|
||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||
|
||
// Disposing the embed tears down the hung iframe and returns the user to the
|
||
// prescreen, from which they can join again ("Retry") or simply walk away.
|
||
const dismiss = () => setCallEmbed(undefined);
|
||
|
||
return (
|
||
<Box
|
||
className={css.CallViewContent}
|
||
direction="Column"
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
gap="300"
|
||
>
|
||
<Icon src={Icons.Warning} size="400" style={{ color: color.Critical.Main }} />
|
||
<Text style={{ color: color.Critical.Main }} size="L400" align="Center">
|
||
The call failed to load. Check your connection and try again.
|
||
</Text>
|
||
<Box gap="200" alignItems="Center">
|
||
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
|
||
<Text size="B400">Retry</Text>
|
||
</Button>
|
||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||
<Text size="B400">Leave</Text>
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
type CallJoinedProps = {
|
||
containerRef: RefObject<HTMLDivElement>;
|
||
joined: boolean;
|
||
};
|
||
function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
||
const callEmbed = useCallEmbed();
|
||
|
||
return (
|
||
<Box grow="Yes" direction="Column">
|
||
<Box grow="Yes" ref={containerRef} />
|
||
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
export function CallView() {
|
||
const room = useRoom();
|
||
const callContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||
useCallEmbedPlacementSync(callContainerRef);
|
||
|
||
const callEmbed = useCallEmbed();
|
||
const callJoined = useCallJoined(callEmbed);
|
||
const loadError = useCallLoadError(callEmbed);
|
||
|
||
const isCurrentRoom = callEmbed?.roomId === room.roomId;
|
||
const currentJoined = isCurrentRoom && callJoined;
|
||
// Show the recovery UI when this room's embed failed to load and we never
|
||
// made it into the call (a hung iframe / blank spinner otherwise).
|
||
const showLoadError = isCurrentRoom && !currentJoined && Boolean(loadError);
|
||
|
||
return (
|
||
<Box
|
||
className={ContainerColor({ variant: 'Surface' })}
|
||
style={{ minWidth: toRem(280) }}
|
||
grow="Yes"
|
||
>
|
||
{showLoadError && <CallLoadErrorMessage />}
|
||
{!currentJoined && !showLoadError && <CallPrescreen />}
|
||
{!showLoadError && <CallJoined joined={currentJoined} containerRef={callContainerRef} />}
|
||
</Box>
|
||
);
|
||
}
|