({
});
export const createRoomEncryptionState = () => ({
- type: EventType.RoomEncryption,
+ type: 'm.room.encryption',
state_key: '',
content: {
algorithm: 'm.megolm.v1.aes-sha2',
diff --git a/src/app/components/event-history/EventHistory.tsx b/src/app/components/event-history/EventHistory.tsx
index 810cbd9b0..7fc644262 100644
--- a/src/app/components/event-history/EventHistory.tsx
+++ b/src/app/components/event-history/EventHistory.tsx
@@ -48,7 +48,7 @@ import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl';
import * as css from './EventHistory.css';
-import { EventType, RelationType } from '$types/matrix-sdk';
+import { EventType } from '$types/matrix-sdk';
type EventHistoryProps = {
room: Room;
@@ -104,7 +104,7 @@ export const EventHistory = as<'div', EventHistoryProps>(
const formattedBody =
content?.['m.new_content']?.formatted_body ?? content?.formatted_body ?? '';
const { 'm.relates_to': relation } = startThread
- ? { 'm.relates_to': { rel_type: RelationType.Thread, event_id: replyId } }
+ ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
: replyEvt.getWireContent();
const senderId = replyEvt.getSender();
if (senderId) {
diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx
index dd29bf969..7060af819 100644
--- a/src/app/components/message/PollEvent.tsx
+++ b/src/app/components/message/PollEvent.tsx
@@ -13,7 +13,6 @@ import {
} from 'matrix-js-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';
@@ -189,7 +188,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) {
let newContent: PollResponse = {
'm.relates_to': {
- rel_type: RelationType.Reference,
+ rel_type: 'm.reference',
event_id: eventId,
},
[M_POLL_RESPONSE.name]: {
@@ -221,13 +220,13 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) {
const endContent = {
'm.relates_to': {
- rel_type: RelationType.Reference,
+ rel_type: 'm.reference',
event_id: eventId,
},
'org.matrix.msc3381.poll.end': {},
[M_TEXT.name]: endText,
body: endText,
- msgtype: MsgType.Text,
+ msgtype: 'm.text',
};
mx.sendEvent(
roomId,
diff --git a/src/app/components/message/modals/MessageForward.tsx b/src/app/components/message/modals/MessageForward.tsx
index 01a651995..9752dec20 100644
--- a/src/app/components/message/modals/MessageForward.tsx
+++ b/src/app/components/message/modals/MessageForward.tsx
@@ -6,7 +6,6 @@ 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { useMessageTargetRooms } from '$hooks/useMessageTargetRooms';
@@ -130,7 +129,7 @@ export function MessageForwardInternal({
const eventType = mEvent.getType() as SendEventType;
const originalContent = mEvent.getContent();
- const isTextMessage = originalContent.msgtype === MsgType.Text;
+ const isTextMessage = originalContent.msgtype === 'm.text';
const originalBody = typeof originalContent.body === 'string' ? originalContent.body : '';
const originalFormattedBody =
diff --git a/src/app/cs-api.ts b/src/app/cs-api.ts
index ec985b97b..386ca3d19 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 5162b8b08..c716c65bd 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 (
();
+
+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 000000000..3afc8183e
--- /dev/null
+++ b/src/app/features/call-status/LivekitCallStatus.tsx
@@ -0,0 +1,72 @@
+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 { 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)}
+ />
+ )}
+
+
+
+
+ );
+}
+
+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 000000000..2a8f44df6
--- /dev/null
+++ b/src/app/features/call-status/NativeCallStatus.tsx
@@ -0,0 +1,50 @@
+import { Box } from 'folds';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { ScreenSize, useScreenSize } from '$hooks/useScreenSize';
+import type { NativeCallSession } from '$state/nativeCall';
+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 = session.lifecycle !== '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 000000000..90252a911
--- /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 000000000..24bcd4f1e
--- /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 000000000..c5573c0a1
--- /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 000000000..40d4acfd9
--- /dev/null
+++ b/src/app/features/call/CallView.test.tsx
@@ -0,0 +1,144 @@
+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('./livekitMobileBridge', () => ({
+ 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,
+ 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(3);
+ expect(screen.getByRole('button', { name: 'Mute microphone' })).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Start camera' })).toBeEnabled();
+ 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 62d79cd6d..f250d0bb5 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -13,6 +13,13 @@ import { CallMemberRenderer } from './CallMemberCard';
import { PrescreenControls } from './PrescreenControls';
import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed';
import { canJoinCall } from './callStartCapabilities';
+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 && (