Files
cinny/src/app/features/call/CallView.tsx
T
Lotus CIandClaude Opus 5.5 fff583fca5
CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
feat(call): desktop without WebRTC offers "Open in browser" instead of a dead end
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52
has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc
backend around 2.56), so calls can't work there. Until now the call button
just disappeared, the call room said "Your browser does not support WebRTC"
with Join disabled, and an incoming call couldn't be answered.

In the desktop app (isTauri) without WebRTC:
- call rooms: "Calls aren't available in the desktop app on Linux yet: its
  web engine has no WebRTC" + an "Open in browser" button;
- incoming-call overlay: the same, with "Answer in browser";
- room header: the call button stays, and opens the room in the browser.

The link is the room in the web app (config.json `webAppUrl`, https only,
new key); the user presses Join there. Deliberately not an auto-join link:
a crafted URL must not be able to join a call and open someone's mic. It
opens through the desktop's new-window handler (web/mail schemes only → the
system browser). Without `webAppUrl` the explanation shows with no button;
browsers without WebRTC keep the old message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 16:54:29 -04:00

228 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { CallsUnavailableMessage } from './CallsUnavailable';
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 JoinMessage({
roomId,
hasParticipant,
livekitSupported,
rtcSupported,
}: {
roomId: string;
hasParticipant?: boolean;
livekitSupported?: boolean;
rtcSupported?: boolean;
}) {
if (rtcSupported === false) {
return <CallsUnavailableMessage roomId={roomId} />;
}
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&#39;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;
// [Gitea #30] Voice channel user limit (io.lotus.voice_limit), shared with the
// room-nav join path via useVoiceChannelFull so both agree on "full".
const { channelFull, current: callMembersCount, max: maxUsers } = useVoiceChannelFull(room);
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={callMembersCount} max={maxUsers} />
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
roomId={room.roomId}
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, where they can choose to join again.
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="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Back</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} />}
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
{callEmbed && joined && <LotusDecorationPusher 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>
);
}