Files
element-call/src/room/InCallView.tsx
T

610 lines
18 KiB
TypeScript
Raw Normal View History

2022-05-04 17:09:48 +01:00
/*
Copyright 2022-2024 New Vector Ltd.
2022-05-04 17:09:48 +01:00
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
2022-05-04 17:09:48 +01:00
*/
import {
RoomAudioRenderer,
RoomContext,
useLocalParticipant,
} from "@livekit/components-react";
2024-06-07 17:29:48 -04:00
import { ConnectionState, Room } from "livekit-client";
2023-06-05 20:51:01 +02:00
import { MatrixClient } from "matrix-js-sdk/src/client";
2023-09-22 18:05:13 -04:00
import {
FC,
2024-08-08 17:21:47 -04:00
PointerEvent,
PropsWithoutRef,
2024-08-08 17:21:47 -04:00
TouchEvent,
2024-05-02 18:44:36 -04:00
forwardRef,
2023-09-22 18:05:13 -04:00
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
2023-06-05 20:51:01 +02:00
import useMeasure from "react-use-measure";
2023-08-16 18:41:27 +01:00
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { BehaviorSubject, of } from "rxjs";
2024-05-16 15:23:10 -04:00
import { useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/src/logger";
2022-08-02 00:46:16 +02:00
2023-09-27 19:06:10 -04:00
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import type { IWidgetApiRequest } from "matrix-widget-api";
2022-01-05 15:06:51 -08:00
import {
EndCallButton,
2022-01-05 15:06:51 -08:00
MicButton,
VideoButton,
ShareScreenButton,
2023-05-05 11:44:35 +02:00
SettingsButton,
2022-01-05 15:06:51 -08:00
} from "../button";
2023-08-16 18:41:27 +01:00
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
2022-10-14 16:17:50 +02:00
import { useUrlParams } from "../UrlParams";
2023-06-05 20:51:01 +02:00
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css";
2024-05-02 18:44:36 -04:00
import { GridTile } from "../tile/GridTile";
2023-03-16 14:41:55 +00:00
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
2023-12-01 17:43:09 -05:00
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
2023-05-22 15:30:29 -04:00
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
2024-04-23 15:15:13 +02:00
import { useLiveKit } from "../livekit/useLiveKit";
2023-07-21 00:51:07 -04:00
import { useWakeLock } from "../useWakeLock";
2023-08-02 15:29:37 -04:00
import { useMergedRefs } from "../useMergedRefs";
import { MuteStates } from "./MuteStates";
import { MatrixInfo } from "./VideoPreview";
2023-09-27 15:17:04 -04:00
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import { ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import { CallViewModel, GridMode, Layout } from "../state/CallViewModel";
2024-05-02 18:44:36 -04:00
import { Grid, TileProps } from "../grid/Grid";
import { useObservable } from "../state/useObservable";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
2024-04-23 15:15:13 +02:00
import { EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
2024-05-17 16:38:00 -04:00
import { makeGridLayout } from "../grid/GridLayout";
2024-06-07 16:59:56 -04:00
import {
CallLayoutOutputs,
2024-06-07 16:59:56 -04:00
TileModel,
defaultPipAlignment,
defaultSpotlightAlignment,
} from "../grid/CallLayout";
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
2022-01-05 15:06:51 -08:00
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
2024-05-02 18:44:36 -04:00
2024-08-08 17:21:47 -04:00
const maxTapDurationMs = 400;
export interface ActiveCallProps
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
2024-04-23 15:15:13 +02:00
e2eeSystem: EncryptionSystem;
2023-06-16 18:07:13 +02:00
}
2023-09-22 18:05:13 -04:00
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLiveKit(
props.rtcSession,
props.muteStates,
sfuConfig,
2024-04-23 15:15:13 +02:00
props.e2eeSystem,
);
const connStateObservable = useObservable(connState);
const [vm, setVm] = useState<CallViewModel | null>(null);
useEffect(() => {
2024-06-04 11:20:25 -04:00
return (): void => {
livekitRoom?.disconnect().catch((e) => {
logger.error("Failed to disconnect from livekit room", e);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (livekitRoom !== undefined) {
const vm = new CallViewModel(
props.rtcSession.room,
livekitRoom,
props.e2eeSystem.kind !== E2eeType.NONE,
connStateObservable,
);
setVm(vm);
return (): void => vm.destroy();
}
}, [
props.rtcSession.room,
livekitRoom,
props.e2eeSystem.kind,
connStateObservable,
]);
if (livekitRoom === undefined || vm === null) return null;
2023-06-30 16:43:28 +01:00
return (
<RoomContext.Provider value={livekitRoom}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
2023-06-30 16:43:28 +01:00
</RoomContext.Provider>
);
2023-09-22 18:05:13 -04:00
};
export interface InCallViewProps {
2022-08-02 00:46:16 +02:00
client: MatrixClient;
vm: CallViewModel;
matrixInfo: MatrixInfo;
2023-08-16 18:41:27 +01:00
rtcSession: MatrixRTCSession;
2023-06-16 18:07:13 +02:00
livekitRoom: Room;
2023-08-02 15:29:37 -04:00
muteStates: MuteStates;
participantCount: number;
onLeave: (error?: Error) => void;
hideHeader: boolean;
2023-06-30 16:43:28 +01:00
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
2022-08-02 00:46:16 +02:00
}
2022-08-08 20:01:58 +02:00
2024-05-16 15:23:10 -04:00
export const InCallView: FC<InCallViewProps> = ({
client,
vm,
2024-05-16 15:23:10 -04:00
matrixInfo,
rtcSession,
livekitRoom,
muteStates,
participantCount,
onLeave,
hideHeader,
connState,
onShareClick,
}) => {
useWakeLock();
useEffect(() => {
if (connState === ConnectionState.Disconnected) {
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
onLeave(new Error("Disconnected from call server"));
}
}, [connState, onLeave]);
2022-10-14 09:40:21 -04:00
2024-05-16 15:23:10 -04:00
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure();
const boundsValid = bounds.height > 0;
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
2024-05-16 15:23:10 -04:00
const { hideScreensharing, showControls } = useUrlParams();
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
room: livekitRoom,
});
const toggleMicrophone = useCallback(
() => muteStates.audio.setEnabled?.((e) => !e),
[muteStates],
);
const toggleCamera = useCallback(
() => muteStates.video.setEnabled?.((e) => !e),
[muteStates],
);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts(
containerRef1,
toggleMicrophone,
toggleCamera,
(muted) => muteStates.audio.setEnabled?.(!muted),
);
2024-05-16 15:23:10 -04:00
const mobile = boundsValid && bounds.width <= 660;
const reducedControls = boundsValid && bounds.width <= 340;
const noControls = reducedControls && bounds.height <= 400;
const windowMode = useObservableEagerState(vm.windowMode);
2024-05-16 15:23:10 -04:00
const layout = useObservableEagerState(vm.layout);
2024-06-07 17:29:48 -04:00
const gridMode = useObservableEagerState(vm.gridMode);
2024-08-08 17:21:47 -04:00
const showHeader = useObservableEagerState(vm.showHeader);
const showFooter = useObservableEagerState(vm.showFooter);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
// in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility
// Instead we have to watch for sufficiently fast touch events.
const touchStart = useRef<number | null>(null);
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
const onTouchEnd = useCallback(() => {
const start = touchStart.current;
if (start !== null && Date.now() - start <= maxTapDurationMs)
vm.tapScreen();
touchStart.current = null;
}, [vm]);
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
// We also need to tell the layout toggle to prevent touch events from
// bubbling up, or else the controls will be dismissed before a change event
// can be registered on the toggle
const onLayoutToggleTouchEnd = useCallback(
(e: TouchEvent) => e.stopPropagation(),
[],
);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (e.pointerType === "mouse") vm.hoverScreen();
},
[vm],
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
2024-05-02 18:44:36 -04:00
2024-05-16 15:23:10 -04:00
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
2024-05-02 18:44:36 -04:00
2024-05-16 15:23:10 -04:00
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
2024-05-02 18:44:36 -04:00
2024-05-16 15:23:10 -04:00
const openProfile = useCallback(() => {
setSettingsTab("profile");
setSettingsModalOpen(true);
}, [setSettingsTab, setSettingsModalOpen]);
const [headerRef, headerBounds] = useMeasure();
const [footerRef, footerBounds] = useMeasure();
2024-05-17 16:38:00 -04:00
2024-05-16 15:23:10 -04:00
const gridBounds = useMemo(
() => ({
width: bounds.width,
height:
bounds.height -
headerBounds.height -
(windowMode === "flat" ? 0 : footerBounds.height),
2024-05-16 15:23:10 -04:00
}),
[
bounds.width,
2024-05-16 15:23:10 -04:00
bounds.height,
headerBounds.height,
footerBounds.height,
windowMode,
2024-05-16 15:23:10 -04:00
],
);
const gridBoundsObservable = useObservable(gridBounds);
2024-05-17 16:38:00 -04:00
2024-06-07 16:59:56 -04:00
const spotlightAlignment = useInitial(
() => new BehaviorSubject(defaultSpotlightAlignment),
);
const pipAlignment = useInitial(
() => new BehaviorSubject(defaultPipAlignment),
2024-05-16 15:23:10 -04:00
);
2024-05-17 16:38:00 -04:00
2024-05-16 15:23:10 -04:00
const setGridMode = useCallback(
2024-06-07 17:29:48 -04:00
(mode: GridMode) => vm.setGridMode(mode),
[vm],
2024-05-16 15:23:10 -04:00
);
2024-05-02 18:44:36 -04:00
2024-06-07 17:29:48 -04:00
useEffect(() => {
widget?.api.transport
.send(
gridMode === "grid"
? ElementWidgetActions.TileLayout
: ElementWidgetActions.SpotlightLayout,
{},
)
.catch((e) => {
logger.error("Failed to send layout change to widget API", e);
});
2024-06-07 17:29:48 -04:00
}, [gridMode]);
useEffect(() => {
if (widget) {
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setGridMode("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setGridMode("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
widget.lazyActions.on(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
return (): void => {
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
);
};
}
}, [setGridMode]);
const Tile = useMemo(
2024-05-16 15:23:10 -04:00
() =>
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<TileModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
ref,
) {
const spotlightExpanded = useObservableEagerState(vm.spotlightExpanded);
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded,
);
const showVideo = useObservableEagerState(
useMemo(
() =>
model.type === "grid" ? vm.showGridVideo(model.vm) : of(true),
[model],
),
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators,
);
const showSpotlightIndicatorsValue = useObservableEagerState(
vm.showSpotlightIndicators,
);
return model.type === "grid" ? (
<GridTile
ref={ref}
vm={model.vm}
onOpenProfile={openProfile}
targetWidth={targetWidth}
targetHeight={targetHeight}
className={classNames(className, styles.tile)}
style={style}
showVideo={showVideo}
showSpeakingIndicators={showSpeakingIndicatorsValue}
/>
) : (
<SpotlightTile
ref={ref}
vms={model.vms}
maximised={model.maximised}
expanded={spotlightExpanded}
onToggleExpanded={onToggleExpanded}
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
className={classNames(className, styles.tile)}
style={style}
/>
);
}),
[vm, openProfile],
2024-05-16 15:23:10 -04:00
);
const layouts = useMemo(() => {
const inputs = {
minBounds: gridBoundsObservable,
spotlightAlignment,
pipAlignment,
};
return {
grid: makeGridLayout(inputs),
2024-07-18 11:24:18 -04:00
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one": makeOneOnOneLayout(inputs),
};
}, [gridBoundsObservable, spotlightAlignment, pipAlignment]);
2024-05-16 15:23:10 -04:00
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
vms={layout.spotlight!}
maximised
expanded
onToggleExpanded={null}
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
/>
);
2024-05-16 15:23:10 -04:00
}
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
const fixedGrid = (
<Grid
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart: headerBounds.bottom,
height: gridBounds.height,
}}
model={layout}
Layout={layers.fixed}
Tile={Tile}
/>
);
const scrollingGrid = (
<Grid
key="scrolling"
className={styles.scrollingGrid}
model={layout}
Layout={layers.scrolling}
Tile={Tile}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
// *over* the spotlight in the expanded layout
2024-07-18 11:24:18 -04:00
return layout.type === "spotlight-expanded" ? (
2024-06-07 17:29:48 -04:00
<>
{fixedGrid}
{scrollingGrid}
</>
) : (
<>
{scrollingGrid}
{fixedGrid}
2024-06-07 17:29:48 -04:00
</>
);
2024-05-16 15:23:10 -04:00
};
2023-09-04 12:46:06 +01:00
2024-05-16 15:23:10 -04:00
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId,
);
const toggleScreensharing = useCallback(() => {
localParticipant
.setScreenShareEnabled(!isScreenShareEnabled, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
})
.catch(logger.error);
}, [localParticipant, isScreenShareEnabled]);
2024-05-16 15:23:10 -04:00
let footer: JSX.Element | null;
if (noControls) {
footer = null;
} else {
const buttons: JSX.Element[] = [];
buttons.push(
<MicButton
key="1"
muted={!muteStates.audio.enabled}
onClick={toggleMicrophone}
2024-05-16 15:23:10 -04:00
disabled={muteStates.audio.setEnabled === null}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={!muteStates.video.enabled}
onClick={toggleCamera}
2024-05-16 15:23:10 -04:00
disabled={muteStates.video.setEnabled === null}
data-testid="incall_videomute"
/>,
);
if (!reducedControls) {
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ShareScreenButton
2024-05-16 15:23:10 -04:00
key="3"
enabled={isScreenShareEnabled}
onClick={toggleScreensharing}
2024-05-16 15:23:10 -04:00
data-testid="incall_screenshare"
/>,
);
}
buttons.push(<SettingsButton key="4" onClick={openSettings} />);
2023-09-04 12:46:06 +01:00
}
2024-05-16 15:23:10 -04:00
buttons.push(
<EndCallButton
2024-05-16 15:23:10 -04:00
key="6"
onClick={function (): void {
2024-05-16 15:23:10 -04:00
onLeave();
}}
data-testid="incall_leave"
/>,
);
footer = (
<div
ref={footerRef}
2024-08-08 17:21:47 -04:00
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader),
})}
2024-05-16 15:23:10 -04:00
>
{!mobile && !hideHeader && (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
</div>
)}
{showControls && <div className={styles.buttons}>{buttons}</div>}
2024-07-25 11:32:05 +02:00
{!mobile && showControls && (
2024-05-16 15:23:10 -04:00
<LayoutToggle
className={styles.layout}
2024-06-07 17:29:48 -04:00
layout={gridMode}
2024-05-16 15:23:10 -04:00
setLayout={setGridMode}
2024-08-08 17:21:47 -04:00
onTouchEnd={onLayoutToggleTouchEnd}
2024-05-16 15:23:10 -04:00
/>
)}
</div>
);
2024-05-16 15:23:10 -04:00
}
return (
2024-08-08 17:21:47 -04:00
<div
className={styles.inRoom}
ref={containerRef}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
{showHeader &&
2024-08-01 16:25:33 -04:00
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
) : (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{!reducedControls && showControls && onShareClick !== null && (
<InviteButton onClick={onShareClick} />
)}
</RightNav>
</Header>
))}
2024-05-16 15:23:10 -04:00
<RoomAudioRenderer />
{renderContent()}
{footer}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
</div>
);
};