({
- Box: ({ children, direction, className, style }: any) => (
-
+ Box: ({ children, direction, className, style, onClick }: any) => (
+
{children}
),
@@ -19,9 +27,19 @@ vi.mock('folds', () => ({
{children}
),
- Overlay: ({ children, open }: any) => (open ?
{children}
: null),
+ Overlay: ({ children, open, backdrop }: any) =>
+ open ? (
+
+ {backdrop}
+ {children}
+
+ ) : null,
OverlayBackdrop: () =>
,
- OverlayCenter: ({ children }: any) =>
{children}
,
+ OverlayCenter: ({ children, onClick }: any) => (
+
+ {children}
+
+ ),
}));
vi.mock('$utils/androidBack', () => ({
@@ -98,6 +116,7 @@ function renderMenuMobile(props: {
menu: React.ReactNode;
children?: React.ReactNode;
mobile?: 'sheet' | 'dialog';
+ requestClose?: () => void;
}) {
return render(
@@ -280,6 +299,36 @@ describe('ResponsiveMenu', () => {
expect(screen.getByTestId('focus-trap')).toBeTruthy();
});
+
+ // Wiring dismissal to the backdrop instead leaves the dialog untappable:
+ // OverlayCenter paints over it.
+ it('closes when the area outside the dialog is tapped', () => {
+ const requestClose = vi.fn<() => void>();
+ renderMenuMobile({
+ anchor: anchorRect,
+ menu: ,
+ mobile: 'dialog',
+ requestClose,
+ });
+
+ fireEvent.click(screen.getByTestId('overlay-center'));
+
+ expect(requestClose).toHaveBeenCalledOnce();
+ });
+
+ it('stays open when the dialog itself is tapped', () => {
+ const requestClose = vi.fn<() => void>();
+ renderMenuMobile({
+ anchor: anchorRect,
+ menu: ,
+ mobile: 'dialog',
+ requestClose,
+ });
+
+ fireEvent.click(screen.getByTestId('sample-menu'));
+
+ expect(requestClose).not.toHaveBeenCalled();
+ });
});
describe('escape closes on both branches', () => {
diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx
index 6950360628..0f81c5fad6 100644
--- a/src/app/components/ResponsiveMenu.tsx
+++ b/src/app/components/ResponsiveMenu.tsx
@@ -45,10 +45,19 @@ function MenuDialog({
useDismissOnBack(requestClose);
return (
+ // The focus trap allows an outside tap on mobile but never deactivates on
+ // it. OverlayCenter, not the backdrop, is what the tap lands on: it fills
+ // the overlay and paints over it.
}>
-
+
-
+ evt.stopPropagation()}
+ >
{children}
diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx
index dd29bf9691..6fe6b231c5 100644
--- a/src/app/components/message/PollEvent.tsx
+++ b/src/app/components/message/PollEvent.tsx
@@ -11,9 +11,9 @@ import {
RoomEvent,
type MatrixEvent,
} from 'matrix-js-sdk';
+import { MsgType, RelationType } from '$types/matrix-sdk';
import * as css from './PollEvent.css';
import { useCallback, useEffect, useState } from 'react';
-import { MsgType, RelationType } from '$types/matrix-sdk';
import { PollResponsesViewer } from '$features/room/poll-modals';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';
import { useMatrixEvent } from '$hooks/useMatrixEvent';
diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx
index 01a6519951..82efe211d0 100644
--- a/src/app/components/message/modals/MessageForward.tsx
+++ b/src/app/components/message/modals/MessageForward.tsx
@@ -5,8 +5,7 @@ import { modalAtom, ModalType } from '$state/modal';
import { MenuItem, Text, as } from 'folds';
import { ArrowRight, menuIcon } from '$components/icons/phosphor';
import { useSetAtom } from 'jotai';
-import type { MatrixEvent, Room } from '$types/matrix-sdk';
-import { MsgType } from '$types/matrix-sdk';
+import { MsgType, type MatrixEvent, type Room } from '$types/matrix-sdk';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { useMessageTargetRooms } from '$hooks/useMessageTargetRooms';
diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts
index ec985b97b4..386ca3d19b 100644
--- a/src/app/cs-api.ts
+++ b/src/app/cs-api.ts
@@ -1,4 +1,5 @@
import to from 'await-to-js';
+import type { LivekitTransportConfig } from '$types/matrix-sdk';
import { trimTrailingSlash } from './utils/common';
export enum AutoDiscoveryAction {
@@ -22,12 +23,22 @@ export type AutoDiscoveryInfo = Record & {
account?: string;
issuer?: string;
};
- 'org.matrix.msc4143.rtc_foci'?: [
- {
- livekit_service_url: string;
- type: 'livekit';
- },
- ];
+ 'org.matrix.msc4143.rtc_foci'?: LivekitTransportConfig[];
+};
+
+export const getLivekitTransports = (
+ discovery: Pick | undefined
+): LivekitTransportConfig[] => {
+ const foci = discovery?.['org.matrix.msc4143.rtc_foci'];
+ if (!Array.isArray(foci)) return [];
+
+ return foci.filter(
+ (focus): focus is LivekitTransportConfig =>
+ typeof focus === 'object' &&
+ focus !== null &&
+ focus.type === 'livekit' &&
+ typeof focus.livekit_service_url === 'string'
+ );
};
export const autoDiscovery = async (
diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 5162b8b08d..c716c65bd5 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -23,7 +23,7 @@ type MicrophoneButtonProps = {
onToggle: () => Promise;
disabled?: boolean;
};
-function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
+export function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
return (
void;
disabled?: boolean;
};
-function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
+export function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
return (
Promise;
disabled?: boolean;
};
-function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
+export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
return (
void;
disabled?: boolean;
};
-function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
+export function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonProps) {
return (
string[]>(() => []);
+
+vi.mock('$hooks/useCall', () => ({
+ useCallSession: () => undefined,
+ useCallMembers: () => callMembers(),
+}));
+
+vi.mock('./LiveChip', () => ({
+ LiveChip: ({ count }: { count: number }) => {count}
,
+}));
+vi.mock('./CallRoomName', () => ({
+ CallRoomName: ({ room }: { room: Room }) => {room.name}
,
+}));
+vi.mock('./MemberGlance', () => ({
+ MemberGlance: () => ,
+}));
+
+const room = { roomId: '!room:test', name: 'Standup' } as Room;
+
+describe('HangupChip', () => {
+ it('hangs up when pressed', async () => {
+ const onHangup = vi.fn<() => Promise>(() => Promise.resolve());
+
+ render();
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(onHangup).toHaveBeenCalledOnce();
+ });
+
+ it('disables itself while the hangup is in flight, so it cannot be double-fired', async () => {
+ let settle = () => {};
+ const onHangup = vi.fn<() => Promise>(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ })
+ );
+
+ render();
+ const button = screen.getByRole('button');
+ await userEvent.click(button);
+
+ await waitFor(() => expect(button.hasAttribute('disabled')).toBe(true));
+
+ await userEvent.click(button);
+ expect(onHangup).toHaveBeenCalledOnce();
+
+ settle();
+ });
+
+ it('drops the "End" label when compact, leaving the icon', () => {
+ const { rerender } = render(
+ Promise>()} />
+ );
+ expect(screen.getByText('End')).toBeTruthy();
+
+ rerender( Promise>()} />);
+ expect(screen.queryByText('End')).toBeNull();
+ });
+});
+
+describe('CallStatusShell', () => {
+ const renderShell = (props: Partial[0]> = {}) =>
+ render(
+ controls}
+ {...props}
+ />
+ );
+
+ it('renders the engine-specific controls it is handed', () => {
+ renderShell();
+
+ expect(screen.getByRole('button', { name: 'controls' })).toBeTruthy();
+ });
+
+ it('spins instead of showing a roster until the call is connected', () => {
+ callMembers.mockReturnValue(['@alice:test']);
+ renderShell({ connected: false });
+
+ expect(screen.queryByTestId('live-chip')).toBeNull();
+ expect(screen.queryByTestId('member-glance')).toBeNull();
+ });
+
+ it('spins while connected but the roster is still empty', () => {
+ callMembers.mockReturnValue([]);
+ renderShell();
+
+ expect(screen.queryByTestId('live-chip')).toBeNull();
+ });
+
+ it('shows the roster once connected with members', () => {
+ callMembers.mockReturnValue(['@alice:test', '@bob:test']);
+ renderShell();
+
+ expect(screen.getByTestId('live-chip').textContent).toBe('2');
+ expect(screen.getByTestId('member-glance')).toBeTruthy();
+ });
+
+ it('renders the room name in both layouts', () => {
+ callMembers.mockReturnValue(['@alice:test']);
+
+ const { rerender } = renderShell();
+ expect(screen.getByTestId('room-name').textContent).toBe('Standup');
+
+ rerender(} />);
+ expect(screen.getByTestId('room-name').textContent).toBe('Standup');
+ });
+});
diff --git a/src/app/features/call-status/CallStatusShell.tsx b/src/app/features/call-status/CallStatusShell.tsx
new file mode 100644
index 0000000000..aff841ed40
--- /dev/null
+++ b/src/app/features/call-status/CallStatusShell.tsx
@@ -0,0 +1,108 @@
+import { useCallback, type ReactNode } from 'react';
+import { Box, Chip, Spinner, Text } from 'folds';
+import classNames from 'classnames';
+import { PhoneDisconnect, sizedIcon } from '$components/icons/phosphor';
+import type { Room } from '$types/matrix-sdk';
+import { useCallMembers, useCallSession } from '$hooks/useCall';
+import { useAsyncCallback, AsyncStatus } from '$hooks/useAsyncCallback';
+import { ContainerColor } from '$styles/ContainerColor.css';
+import { LiveChip } from './LiveChip';
+import { CallRoomName } from './CallRoomName';
+import { MemberGlance } from './MemberGlance';
+import { StatusDivider } from './components';
+import * as css from './styles.css';
+
+// Speaker detection belongs to the in-call surface; the bar only needs the
+// roster, so it opts out of the highlight.
+const noSpeakers = new Set();
+
+export function HangupChip({
+ compact,
+ onHangup,
+}: {
+ compact: boolean;
+ onHangup: () => Promise;
+}) {
+ const [hangupState, hangup] = useAsyncCallback(useCallback(() => onHangup(), [onHangup]));
+ const exiting =
+ hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;
+
+ return (
+
+ ) : (
+ sizedIcon(PhoneDisconnect, '50', { filled: true })
+ )
+ }
+ disabled={exiting}
+ outlined
+ onClick={() => hangup()}
+ >
+ {!compact && (
+
+ End
+
+ )}
+
+ );
+}
+
+/**
+ * The persistent call bar both engines render into. Only the control cluster
+ * differs, so it is passed in.
+ */
+export function CallStatusShell({
+ room,
+ compact,
+ connected,
+ controls,
+}: {
+ room: Room;
+ compact: boolean;
+ connected: boolean;
+ controls: ReactNode;
+}) {
+ const callSession = useCallSession(room);
+ const callMembers = useCallMembers(room, callSession);
+ const memberVisible = connected && callMembers.length > 0;
+
+ return (
+
+
+ {memberVisible ? (
+
+
+
+ ) : (
+
+ )}
+
+ {!compact && }
+
+ {memberVisible && (
+
+
+
+ )}
+
+ {memberVisible && !compact && }
+
+ {compact && (
+
+
+
+ )}
+ {controls}
+
+
+ );
+}
diff --git a/src/app/features/call-status/LivekitCallStatus.tsx b/src/app/features/call-status/LivekitCallStatus.tsx
new file mode 100644
index 0000000000..a19de53637
--- /dev/null
+++ b/src/app/features/call-status/LivekitCallStatus.tsx
@@ -0,0 +1,78 @@
+import { Box } from 'folds';
+import { useAtom } from 'jotai';
+import { RoomContext, useLocalParticipant } from '@livekit/components-react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { ScreenSize, useScreenSize } from '$hooks/useScreenSize';
+import { livekitJsCallSoundAtom, type LivekitJsCallSession } from '$state/livekitJsCall';
+import { livekitJsCallStatus } from '$features/call/callClient';
+import { MicrophoneButton, ScreenShareButton, SoundButton, VideoButton } from './CallControl';
+import { CallStatusShell, HangupChip } from './CallStatusShell';
+import { StatusDivider } from './components';
+
+function LivekitCallControl({
+ compact,
+ onHangup,
+}: {
+ compact: boolean;
+ onHangup: () => Promise;
+}) {
+ const { localParticipant, isMicrophoneEnabled, isCameraEnabled, isScreenShareEnabled } =
+ useLocalParticipant();
+ const [sound, setSound] = useAtom(livekitJsCallSoundAtom);
+
+ return (
+
+
+ localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled)}
+ />
+ setSound(!sound)} />
+ {!compact && }
+ localParticipant.setCameraEnabled(!isCameraEnabled)}
+ />
+ {!compact && (
+
+ void localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
+ audio: true,
+ selfBrowserSurface: 'exclude',
+ })
+ }
+ />
+ )}
+
+
+
+
+ );
+}
+
+export function LivekitCallStatus({ session }: { session: LivekitJsCallSession }) {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSize();
+ const room = mx.getRoom(session.roomId);
+ const compact = screenSize === ScreenSize.Mobile;
+
+ if (!room) return null;
+
+ return (
+
+
+
+ ) : (
+
+ )
+ }
+ />
+ );
+}
diff --git a/src/app/features/call-status/NativeCallStatus.tsx b/src/app/features/call-status/NativeCallStatus.tsx
new file mode 100644
index 0000000000..3bf30c9337
--- /dev/null
+++ b/src/app/features/call-status/NativeCallStatus.tsx
@@ -0,0 +1,51 @@
+import { Box } from 'folds';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { ScreenSize, useScreenSize } from '$hooks/useScreenSize';
+import type { NativeCallSession } from '$state/nativeCall';
+import { nativeCallStatus } from '$features/call/callClient';
+import { MicrophoneButton, VideoButton } from './CallControl';
+import { CallStatusShell, HangupChip } from './CallStatusShell';
+import { StatusDivider } from './components';
+
+function NativeCallControl({ session, compact }: { session: NativeCallSession; compact: boolean }) {
+ // Media commands are rejected until the native room has connected.
+ const disabled = nativeCallStatus(session).phase !== 'connected';
+
+ return (
+
+
+ session.setMicrophoneEnabled(!session.microphoneEnabled)}
+ disabled={disabled}
+ />
+ {!compact && }
+ session.setCameraEnabled(!session.cameraEnabled)}
+ disabled={disabled}
+ />
+
+
+
+
+ );
+}
+
+export function NativeCallStatus({ session }: { session: NativeCallSession }) {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSize();
+ const room = mx.getRoom(session.roomId);
+ const compact = screenSize === ScreenSize.Mobile;
+
+ if (!room) return null;
+
+ return (
+ }
+ />
+ );
+}
diff --git a/src/app/features/call/CallDevicePreview.css.ts b/src/app/features/call/CallDevicePreview.css.ts
new file mode 100644
index 0000000000..90252a9110
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.css.ts
@@ -0,0 +1,51 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const PreviewSurface = style({
+ position: 'relative',
+ width: '100%',
+ aspectRatio: '16 / 9',
+ borderRadius: config.radii.R400,
+ background: '#14171f',
+ color: color.Surface.OnContainer,
+ overflow: 'hidden',
+});
+
+export const PreviewVideo = style({
+ position: 'absolute',
+ inset: 0,
+ width: '100%',
+ height: '100%',
+ objectFit: 'cover',
+ // Front cameras read as a mirror to the person looking at them.
+ transform: 'scaleX(-1)',
+});
+
+export const DeviceSelect = style({
+ width: '100%',
+ minWidth: 0,
+ minHeight: toRem(36),
+ padding: `0 ${config.space.S200}`,
+ borderRadius: config.radii.R400,
+ border: `1px solid ${color.Surface.ContainerLine}`,
+ background: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ font: 'inherit',
+ fontSize: toRem(14),
+});
+
+export const LevelTrack = style({
+ width: '100%',
+ height: toRem(6),
+ borderRadius: config.radii.R400,
+ background: color.Surface.ContainerLine,
+ overflow: 'hidden',
+});
+
+export const LevelFill = style({
+ width: '100%',
+ height: '100%',
+ transformOrigin: 'left center',
+ background: color.Success.Main,
+ transition: 'transform 80ms linear',
+});
diff --git a/src/app/features/call/CallDevicePreview.test.tsx b/src/app/features/call/CallDevicePreview.test.tsx
new file mode 100644
index 0000000000..24bcd4f1e5
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.test.tsx
@@ -0,0 +1,96 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CallDevicePreview } from './CallDevicePreview';
+
+const mocks = vi.hoisted(() => ({
+ usePreviewTracks: vi.fn<() => unknown[] | undefined>(),
+ useMediaDeviceSelect: vi.fn<(o: { kind: string }) => { devices: MediaDeviceInfo[] }>(),
+ previewOptions: undefined as unknown,
+ attach: vi.fn<(el: HTMLElement) => void>(),
+ detach: vi.fn<(el: HTMLElement) => void>(),
+}));
+
+vi.mock('@livekit/components-react', () => ({
+ // Mirror the real hook: it only returns tracks for the sources requested.
+ usePreviewTracks: (options: { audio: unknown; video: unknown }) => {
+ mocks.previewOptions = options;
+ const tracks = mocks.usePreviewTracks() ?? [];
+ return tracks.filter((track) => {
+ const { kind } = track as { kind: string };
+ return kind === 'video' ? options.video !== false : options.audio !== false;
+ });
+ },
+ useMediaDeviceSelect: (options: { kind: string }) => mocks.useMediaDeviceSelect(options),
+ useTrackVolume: () => 0.25,
+}));
+
+vi.mock('livekit-client', () => ({
+ Track: { Kind: { Video: 'video', Audio: 'audio' } },
+}));
+
+const device = (deviceId: string, label: string, kind: string) =>
+ ({ deviceId, label, kind }) as MediaDeviceInfo;
+
+const videoTrack = { kind: 'video', attach: mocks.attach, detach: mocks.detach };
+const audioTrack = { kind: 'audio' };
+
+const props = {
+ microphone: true,
+ video: true,
+ onAudioDeviceChange: vi.fn<(id: string) => void>(),
+ onVideoDeviceChange: vi.fn<(id: string) => void>(),
+};
+
+beforeEach(() => {
+ mocks.usePreviewTracks.mockReset().mockReturnValue([videoTrack, audioTrack]);
+ mocks.useMediaDeviceSelect.mockReset().mockImplementation(({ kind }) => ({
+ devices:
+ kind === 'audioinput'
+ ? [device('mic-1', 'Built-in Mic', kind), device('mic-2', 'USB Mic', kind)]
+ : [device('cam-1', 'FaceTime HD', kind)],
+ }));
+ mocks.attach.mockReset();
+ mocks.detach.mockReset();
+ props.onAudioDeviceChange.mockReset();
+ props.onVideoDeviceChange.mockReset();
+});
+
+describe('CallDevicePreview', () => {
+ it('attaches the preview camera track to a video element', () => {
+ render();
+
+ expect(mocks.attach).toHaveBeenCalledOnce();
+ expect(screen.queryByText('Camera is off')).not.toBeInTheDocument();
+ });
+
+ it('detaches the camera track on unmount so the call can claim it', () => {
+ const { unmount } = render();
+ unmount();
+
+ expect(mocks.detach).toHaveBeenCalledOnce();
+ });
+
+ it('requests only the devices the user actually enabled', () => {
+ render();
+
+ expect(mocks.previewOptions).toEqual({ audio: { deviceId: 'mic-2' }, video: false });
+ expect(screen.getByText('Camera is off')).toBeInTheDocument();
+ });
+
+ it('reports a chosen microphone so the call can honour it', async () => {
+ render();
+
+ await userEvent.selectOptions(screen.getByLabelText('Microphone'), 'mic-2');
+
+ expect(props.onAudioDeviceChange).toHaveBeenCalledWith('mic-2');
+ });
+
+ it('shows a microphone level only while the microphone is on', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('meter', { name: 'Microphone level' })).toBeInTheDocument();
+
+ rerender();
+ expect(screen.queryByRole('meter', { name: 'Microphone level' })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/app/features/call/CallDevicePreview.tsx b/src/app/features/call/CallDevicePreview.tsx
new file mode 100644
index 0000000000..c5573c0a13
--- /dev/null
+++ b/src/app/features/call/CallDevicePreview.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useMemo, useRef } from 'react';
+import { Box, Text, config, toRem } from 'folds';
+import { useMediaDeviceSelect, usePreviewTracks, useTrackVolume } from '@livekit/components-react';
+import { Track, type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client';
+import { VideoCameraSlash, sizedIcon } from '$components/icons/phosphor';
+import * as css from './CallDevicePreview.css';
+
+type DeviceSelectProps = {
+ label: string;
+ kind: MediaDeviceKind;
+ deviceId?: string;
+ onChange: (deviceId: string) => void;
+ permissionsGranted: boolean;
+};
+
+function DeviceSelect({ label, kind, deviceId, onChange, permissionsGranted }: DeviceSelectProps) {
+ const { devices } = useMediaDeviceSelect({ kind, requestPermissions: permissionsGranted });
+
+ return (
+
+ {label}
+
+
+ );
+}
+
+function MicrophoneLevel({ track }: { track?: LocalAudioTrack }) {
+ const volume = useTrackVolume(track);
+ const level = Math.min(1, volume * 3);
+
+ return (
+
+ Microphone level
+
+
+ );
+}
+
+function VideoPreview({ track }: { track?: LocalVideoTrack }) {
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const element = videoRef.current;
+ if (!track || !element) return undefined;
+ track.attach(element);
+ return () => {
+ track.detach(element);
+ };
+ }, [track]);
+
+ if (!track) {
+ return (
+
+ {sizedIcon(VideoCameraSlash, '400')}
+ Camera is off
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export type CallDevicePreviewProps = {
+ microphone: boolean;
+ video: boolean;
+ audioDeviceId?: string;
+ videoDeviceId?: string;
+ onAudioDeviceChange: (deviceId: string) => void;
+ onVideoDeviceChange: (deviceId: string) => void;
+};
+
+export function CallDevicePreview({
+ microphone,
+ video,
+ audioDeviceId,
+ videoDeviceId,
+ onAudioDeviceChange,
+ onVideoDeviceChange,
+}: CallDevicePreviewProps) {
+ const tracks = usePreviewTracks({
+ audio: microphone ? { deviceId: audioDeviceId } : false,
+ video: video ? { deviceId: videoDeviceId } : false,
+ });
+
+ const videoTrack = useMemo(
+ () => tracks?.find((track) => track.kind === Track.Kind.Video) as LocalVideoTrack | undefined,
+ [tracks]
+ );
+ const audioTrack = useMemo(
+ () => tracks?.find((track) => track.kind === Track.Kind.Audio) as LocalAudioTrack | undefined,
+ [tracks]
+ );
+
+ return (
+
+
+ {microphone && }
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/call/CallView.test.tsx b/src/app/features/call/CallView.test.tsx
new file mode 100644
index 0000000000..d04dd37a01
--- /dev/null
+++ b/src/app/features/call/CallView.test.tsx
@@ -0,0 +1,146 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { LivekitJsCallStatus } from './CallView';
+import { NativeCallSurface } from './NativeCallSurface';
+import type { NativeCallSession } from '$state/nativeCall';
+
+vi.mock('@sableclient/tauri-plugin-livekit-mobile', () => ({
+ setNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ clearNativeCallRemoteVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ setNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+ clearNativeCallLocalVideoOverlay: vi.fn<() => Promise>(() => Promise.resolve({})),
+}));
+
+vi.mock('$hooks/useRoom', () => ({ useRoom: () => ({ roomId: '!room:example.org' }) }));
+vi.mock('$hooks/router/useSelectedRoom', () => ({
+ useSelectedRoom: () => '!room:example.org',
+}));
+vi.mock('$hooks/useCall', () => ({ useCallSession: () => ({}), useCallMembers: () => [] }));
+vi.mock('./LivekitCallParticipant', () => ({
+ useCallParticipantProfile: () => ({ name: 'Bob' }),
+ CallParticipantAvatar: () => ,
+}));
+
+const nativeSession = (lifecycle: NativeCallSession['lifecycle']): NativeCallSession => ({
+ backend: 'livekit-mobile',
+ roomId: '!room:example.org',
+ callId: 'call-id',
+ lifecycle,
+ participants: [],
+ microphoneEnabled: true,
+ cameraEnabled: false,
+ screenShareEnabled: false,
+ setMicrophoneEnabled: async () => {},
+ setCameraEnabled: async () => {},
+ switchCamera: async () => {},
+ listAudioRoutes: async () => [],
+ selectAudioRoute: async () => {},
+ hangup: vi.fn<() => Promise>().mockResolvedValue(undefined),
+});
+
+describe('LiveKit JS call status', () => {
+ it('reports progress without exposing backend or transport details', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(screen.getByText('Preparing call')).toBeInTheDocument();
+ expect(screen.queryByText(/livekit|token|url|secret|e2ee/i)).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'End' })).toBeInTheDocument();
+ });
+
+ it('explains a setup failure in plain language', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(screen.getByText('Call failed')).toBeInTheDocument();
+ expect(screen.getByText('Could not connect to the call.')).toBeInTheDocument();
+ expect(screen.queryByText(/token|url|secret|error:/i)).not.toBeInTheDocument();
+ });
+
+ it('gives an unsupported-encryption failure a dismiss route', () => {
+ const onHangup = vi.fn<() => void>();
+ render(
+
+ );
+
+ expect(
+ screen.getByText('Encrypted calls are not supported on this device.')
+ ).toBeInTheDocument();
+ screen.getByRole('button', { name: 'Dismiss' }).click();
+ expect(onHangup).toHaveBeenCalledOnce();
+ });
+});
+
+describe('native call surface', () => {
+ it('shows the local tile and call controls when connected', () => {
+ render( {}} />);
+
+ expect(screen.getByText('You')).toBeInTheDocument();
+ expect(screen.getAllByRole('button')).toHaveLength(4);
+ expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Start camera' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Audio output' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'End call' })).toBeInTheDocument();
+ });
+
+ it('keeps media toggles disabled while connecting', () => {
+ render( {}} />);
+
+ expect(screen.getByText('Connecting')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Start camera' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'End call' })).toBeEnabled();
+ });
+
+ it('renders a remote tile from the participants the session carries', () => {
+ render(
+ {}}
+ />
+ );
+
+ expect(screen.getByText('Bob')).toBeInTheDocument();
+ expect(screen.getByRole('img', { name: 'Poor connection' })).toBeInTheDocument();
+ expect(screen.getByLabelText('Camera off')).toBeInTheDocument();
+ });
+
+ it('gives failed calls an explicit dismiss route', () => {
+ const onHangup = vi.fn<() => void>();
+ render(
+
+ );
+
+ expect(screen.getByText('Call failed')).toBeInTheDocument();
+ expect(screen.getByText('Native call connection failed.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
+ screen.getByRole('button', { name: 'Dismiss' }).click();
+ expect(onHangup).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 62d79cd6d7..5c7220f2a3 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -12,7 +12,14 @@ import * as css from './styles.css';
import { CallMemberRenderer } from './CallMemberCard';
import { PrescreenControls } from './PrescreenControls';
import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed';
-import { canJoinCall } from './callStartCapabilities';
+import { canJoinCall } from '@sableclient/matrixrtc';
+import type { LivekitJsCallSession } from '$state/livekitJsCall';
+import { livekitJsCallAtom } from '$state/livekitJsCall';
+import { nativeCallAtom } from '$state/nativeCall';
+import { LivekitJsCallSurface } from './LivekitJsCallSurface';
+import { NativeCallSurface } from './NativeCallSurface';
+import { CallStatusBar } from './callChrome';
+import { livekitJsCallStatus } from './callClient';
function LivekitServerMissingMessage() {
return (
@@ -80,6 +87,16 @@ function WidgetPreparationErrorMessage({ message }: { message: string }) {
);
}
+export function LivekitJsCallStatus({
+ session,
+ onHangup,
+}: {
+ session: Pick;
+ onHangup: () => void;
+}) {
+ return ;
+}
+
function CallPrescreen() {
const room = useRoom();
const callEmbed = useAtomValue(callEmbedAtom);
@@ -168,11 +185,22 @@ export function CallView({ resizable }: CallViewProps) {
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
-
- const currentJoined = callEmbed?.roomId === room.roomId && callJoined;
-
+ const livekitJsCall = useAtomValue(livekitJsCallAtom);
+ const nativeCall = useAtomValue(nativeCallAtom);
+
+ const livekitJsCallForRoom = livekitJsCall?.roomId === room.roomId ? livekitJsCall : undefined;
+ const nativeCallForRoom = nativeCall?.roomId === room.roomId ? nativeCall : undefined;
+ const livekitJsRoom =
+ livekitJsCallForRoom?.lifecycle === 'active' ? livekitJsCallForRoom.room : undefined;
+ const currentJoined =
+ !livekitJsCallForRoom && !nativeCallForRoom && callEmbed?.roomId === room.roomId && callJoined;
+
+ // A native call renders video tiles and a control bar, which need most of the
+ // viewport; the 0.3 default is sized for the Element Call participant list.
const [heightRatio, setHeightRatio] = useState(isMobile ? 0.3 : 0.72);
const [availableHeight, setAvailableHeight] = useState(0);
+ const effectiveHeightRatio =
+ isMobile && nativeCallForRoom ? Math.max(heightRatio, 0.75) : heightRatio;
useEffect(() => {
if (!resizable || !callViewRef.current) return undefined;
@@ -263,12 +291,18 @@ export function CallView({ resizable }: CallViewProps) {
minWidth: toRem(280),
height: resizable
? availableHeight > 0
- ? `${availableHeight * heightRatio}px`
- : `${heightRatio * 100}dvh`
+ ? `${availableHeight * effectiveHeightRatio}px`
+ : `${effectiveHeightRatio * 100}dvh`
: undefined,
borderBottom: `1px solid var(--sable-surface-container-line)`,
zIndex: 20,
- backgroundColor: currentJoined ? 'transparent' : undefined,
+ backgroundColor:
+ livekitJsRoom || nativeCallForRoom
+ ? color.Background.Container
+ : currentJoined
+ ? 'transparent'
+ : undefined,
+ overflow: livekitJsRoom || nativeCallForRoom ? 'hidden' : undefined,
pointerEvents: currentJoined ? 'none' : 'all',
}}
>
@@ -284,8 +318,27 @@ export function CallView({ resizable }: CallViewProps) {
/>
)}
- {!currentJoined && }
-
+ {!currentJoined && !livekitJsCallForRoom && !nativeCallForRoom && }
+ {livekitJsCallForRoom && livekitJsRoom ? (
+ void livekitJsCallForRoom.hangup()}
+ />
+ ) : livekitJsCallForRoom ? (
+ void livekitJsCallForRoom.hangup()}
+ />
+ ) : nativeCallForRoom ? (
+ void nativeCallForRoom.hangup()}
+ />
+ ) : (
+
+ )}
{resizable && (