mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
The developer setting that overrode the LiveKit service URL becomes a JSON transport, so that a `url`-only or combined transport can be tested against a homeserver that does not advertise one yet. The settings modal shows each LiveKit room's role instead of a never-populated isLocal flag. Folds "Avoid null in transport dev setting" from the same PR. The setting feeds the SDK's transport option; the config fallback becomes a transport the host builds. Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
833 lines
27 KiB
TypeScript
833 lines
27 KiB
TypeScript
/*
|
|
Copyright 2022-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
|
import {
|
|
type FC,
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type JSX,
|
|
type ReactNode,
|
|
} from "react";
|
|
import useMeasure from "react-use-measure";
|
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
|
import classNames from "classnames";
|
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
|
import { useHostBridge } from "../HostBridge.ts";
|
|
import { useRootElement } from "../RootElementContext";
|
|
import { observeElementSize$ } from "../utils/elementSize";
|
|
import styles from "./InCallView.module.css";
|
|
import { GridTile } from "../tile/GridTile";
|
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
|
import { useWakeLock } from "../useWakeLock";
|
|
import { useMergedRefs } from "../useMergedRefs";
|
|
import { type MuteStates } from "../state/MuteStates";
|
|
import { type MatrixInfo } from "./VideoPreview";
|
|
import { InviteButton } from "../button/InviteButton";
|
|
import {
|
|
type CallViewModel,
|
|
callViewModelOptionsFromParams,
|
|
type CaptureSettings,
|
|
createCallViewModel$,
|
|
initialPublishRequests,
|
|
} from "../state/CallViewModel/CallViewModel.ts";
|
|
import { Grid, type TileProps } from "../grid/Grid";
|
|
import { SpotlightTile } from "../tile/SpotlightTile";
|
|
import {
|
|
type EncryptionSystem,
|
|
type MatrixRTCMode,
|
|
} from "@element-hq/matrixrtc-sdk";
|
|
import { E2eeType } from "../e2ee/e2eeType";
|
|
import { makeGridLayout } from "../grid/GridLayout";
|
|
import { type CallLayoutOutputs } from "../grid/CallLayout";
|
|
import { makeOneOnOneDesktopLayout } from "../grid/OneOnOneDesktopLayout";
|
|
import { makeOneOnOneMobileLayout } from "../grid/OneOnOneMobileLayout";
|
|
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
|
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
|
import {
|
|
ReactionsSenderProvider,
|
|
useReactionsSender,
|
|
} from "../reactions/useReactionsSender";
|
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
|
import {
|
|
createRTCSlot,
|
|
type RTCParticipationOptions,
|
|
type RTCSlotOptions,
|
|
constant,
|
|
} from "@element-hq/matrixrtc-sdk";
|
|
import { map } from "rxjs";
|
|
import {
|
|
advancedCamera,
|
|
advancedScreenShare,
|
|
autoGainControlSetting,
|
|
cameraBitrate,
|
|
cameraCodec,
|
|
cameraFramerate,
|
|
cameraResolution,
|
|
customTransport,
|
|
echoCancellationSetting,
|
|
matrixRTCMode as matrixRTCModeSetting,
|
|
noiseSuppressionSetting,
|
|
parseResolution,
|
|
screenShareBitrate,
|
|
screenShareCodec,
|
|
screenShareFramerate,
|
|
screenShareResolution,
|
|
} from "../settings/settings";
|
|
import { Config } from "../config/Config";
|
|
import { createSentCallNotification$ } from "../state/CallViewModel/CallNotificationLifecycle.ts";
|
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
|
import { MemberAudioRenderer } from "../tracks/MemberAudioRenderer.tsx";
|
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
|
import {
|
|
useAppBarHidden,
|
|
useAppBarSecondaryButton,
|
|
useAppBarSubtitle,
|
|
} from "../AppBar.tsx";
|
|
import { useBehavior } from "../useBehavior.ts";
|
|
import { useValueBehavior } from "../useValueBehavior.ts";
|
|
import { Toast } from "../Toast.tsx";
|
|
import overlayStyles from "../Overlay.module.css";
|
|
import { useTrackProcessorState$ } from "../tracks/TrackProcessorContext.tsx";
|
|
import { type Layout } from "../state/layout-types.ts";
|
|
import { ObservableScope } from "../state/ObservableScope.ts";
|
|
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
|
import { SettingsIconButton } from "../button/Button.tsx";
|
|
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
|
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
|
|
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
|
|
import { type ViewModel } from "../state/ViewModel.ts";
|
|
import { RingingStatus } from "../tile/RingingStatus.tsx";
|
|
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
|
|
|
|
declare module "react" {
|
|
interface CSSProperties {
|
|
"--call-view-safe-area-inset-top"?: string;
|
|
"--call-view-safe-area-inset-bottom"?: string;
|
|
}
|
|
}
|
|
|
|
export interface ActiveCallProps extends Omit<
|
|
InCallViewProps,
|
|
"vm" | "footerVm" | "developerSettingsVm"
|
|
> {
|
|
e2eeSystem: EncryptionSystem;
|
|
// TODO refactor those reasons into an enum
|
|
onLeft: (
|
|
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
|
|
) => void;
|
|
}
|
|
|
|
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|
const [vm, setVm] = useState<CallViewModel | null>(null);
|
|
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
|
null,
|
|
);
|
|
const [developerSettingsVm, setDeveloperSettingsVm] =
|
|
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
|
|
|
const urlParams = useUrlParams();
|
|
const hostBridge = useHostBridge();
|
|
const mediaDevices = useMediaDevices();
|
|
const trackProcessorState$ = useTrackProcessorState$();
|
|
// The element we have to draw the call in: the page, or the container a host
|
|
// gave us. Its size, not the window's, decides how the call is laid out.
|
|
const rootElement = useRootElement();
|
|
useEffect(() => {
|
|
rootLogger.info("START CALL VIEW SCOPE");
|
|
const scope = new ObservableScope();
|
|
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
|
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
|
urlParams;
|
|
|
|
const capture = captureSettings();
|
|
const slot = createRTCSlot(
|
|
scope,
|
|
props.client,
|
|
props.matrixRoom,
|
|
rtcSlotOptions(props.e2eeSystem),
|
|
);
|
|
const rtcParticipation = slot.join({
|
|
...rtcParticipationOptions(urlParams),
|
|
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
|
|
});
|
|
|
|
const vm = createCallViewModel$(
|
|
scope,
|
|
rtcParticipation,
|
|
props.matrixRoom,
|
|
mediaDevices,
|
|
props.muteStates,
|
|
{
|
|
...callViewModelOptionsFromParams(urlParams),
|
|
encryptionSystem: props.e2eeSystem,
|
|
capture,
|
|
videoProcessor$: scope.behavior(
|
|
trackProcessorState$.pipe(map((state) => state.processor)),
|
|
),
|
|
hostBridge,
|
|
autoLeaveWhenOthersLeft,
|
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
|
sentCallNotification$: createSentCallNotification$(
|
|
scope,
|
|
props.rtcSession,
|
|
),
|
|
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
|
},
|
|
reactionsReader.raisedHands$,
|
|
reactionsReader.reactions$,
|
|
);
|
|
setVm(vm);
|
|
|
|
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
|
|
|
return (): void => {
|
|
scope.end();
|
|
};
|
|
}, [
|
|
props.rtcSession,
|
|
props.matrixRoom,
|
|
props.muteStates,
|
|
props.e2eeSystem,
|
|
props.onLeft,
|
|
urlParams,
|
|
hostBridge,
|
|
mediaDevices,
|
|
trackProcessorState$,
|
|
props.client,
|
|
rootElement,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (vm === null) return;
|
|
|
|
const scope = new ObservableScope();
|
|
const footerVm = createCallFooterViewModel(
|
|
scope,
|
|
vm,
|
|
props.muteStates,
|
|
mediaDevices,
|
|
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
|
{ showControls: urlParams.showControls, header: urlParams.header },
|
|
);
|
|
setFooterVm(footerVm);
|
|
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
|
|
|
|
return (): void => {
|
|
scope.end();
|
|
};
|
|
}, [
|
|
props.rtcSession,
|
|
props.matrixRoom,
|
|
props.muteStates,
|
|
props.e2eeSystem,
|
|
props.onLeft,
|
|
urlParams,
|
|
mediaDevices,
|
|
trackProcessorState$,
|
|
props.client,
|
|
vm,
|
|
]);
|
|
|
|
if (vm === null) return null;
|
|
if (footerVm === null) return null;
|
|
if (developerSettingsVm === null) return null;
|
|
|
|
return (
|
|
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
|
<InCallView
|
|
{...props}
|
|
vm={vm}
|
|
footerVm={footerVm}
|
|
developerSettingsVm={developerSettingsVm}
|
|
/>
|
|
</ReactionsSenderProvider>
|
|
);
|
|
};
|
|
|
|
export interface InCallViewProps {
|
|
client: MatrixClient;
|
|
vm: CallViewModel;
|
|
footerVm: ViewModel<FooterSnapshot>;
|
|
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
|
|
matrixInfo: MatrixInfo;
|
|
rtcSession: MatrixRTCSession;
|
|
matrixRoom: MatrixRoom;
|
|
muteStates: MuteStates;
|
|
onShareClick: (() => void) | null;
|
|
}
|
|
|
|
export const InCallView: FC<InCallViewProps> = ({
|
|
client,
|
|
vm,
|
|
footerVm,
|
|
developerSettingsVm,
|
|
matrixInfo,
|
|
matrixRoom,
|
|
muteStates,
|
|
onShareClick,
|
|
}) => {
|
|
const logger = rootLogger.getChild("[InCallView]");
|
|
const { t } = useTranslation();
|
|
const hostBridge = useHostBridge();
|
|
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
|
|
|
useWakeLock();
|
|
|
|
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
|
const [containerRef2, bounds] = useMeasure();
|
|
// Merge the refs so they can attach to the same element
|
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
|
|
|
// The fixed grid is positioned against Element Call's root, so offsets
|
|
// handed to it have to be measured from there rather than from the
|
|
// viewport. Standalone the two are the same, the root being the page; for a
|
|
// component the root sits wherever the host put it, and measuring from the
|
|
// viewport would push the grid down by that much again. Taken at the same
|
|
// moment as `bounds`, so that the two agree however the host has scrolled.
|
|
const rootElement = useRootElement();
|
|
const rootTop = useMemo(
|
|
() => rootElement.getBoundingClientRect().top,
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[rootElement, bounds],
|
|
);
|
|
const topWithinRoot = bounds.top - rootTop;
|
|
|
|
const { showControls, header: headerStyle } = useUrlParams();
|
|
|
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
|
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
|
|
|
useCallViewKeyboardShortcuts(
|
|
toggleAudio,
|
|
toggleVideo,
|
|
setAudioEnabled,
|
|
(reaction) => void sendReaction(reaction),
|
|
() => void toggleRaisedHand(),
|
|
);
|
|
|
|
const ringingVm = useBehavior(vm.ringingVm$);
|
|
const remoteMembers = useBehavior(vm.remoteMembers$);
|
|
const participantCount = useBehavior(vm.participantCount$);
|
|
const reconnecting = useBehavior(vm.reconnecting$);
|
|
const screenShareError = useBehavior(vm.screenShareError$);
|
|
const layout = useBehavior(vm.layout$);
|
|
const edgeToEdge = useBehavior(vm.edgeToEdge$);
|
|
const overflowing = useBehavior(vm.overflowing$);
|
|
const showNameTags = useBehavior(vm.showNameTags$);
|
|
const showHeader = useBehavior(vm.showHeader$);
|
|
const showModals = useBehavior(vm.showModals$);
|
|
const settingsOpen = useBehavior(vm.settingsOpen$);
|
|
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
|
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
|
|
|
const fatalCallError = useBehavior(vm.fatalError$);
|
|
// Stop the rendering and throw for the error boundary
|
|
if (fatalCallError) {
|
|
logger.debug("fatalCallError stop rendering", fatalCallError);
|
|
throw fatalCallError;
|
|
}
|
|
|
|
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
|
|
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
|
// so this still only fires for taps.
|
|
const onViewPointerUp = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
if (
|
|
e.pointerType === "touch" &&
|
|
// If an interactive element was tapped, don't count this as a tap on the screen
|
|
(e.target as Element).closest?.("button, input") === null
|
|
)
|
|
vm.tapScreen();
|
|
},
|
|
[vm],
|
|
);
|
|
|
|
const onPointerMove = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
if (e.pointerType === "mouse") vm.hoverScreen();
|
|
},
|
|
[vm],
|
|
);
|
|
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
|
|
|
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
|
|
|
const openProfile = useMemo(
|
|
() =>
|
|
// The profile is only ours to edit when the account is ours
|
|
hostBridge.supportsProfileChanges
|
|
? (): void => {
|
|
setSettingsTab("profile");
|
|
setSettingsOpen(true);
|
|
}
|
|
: null,
|
|
[setSettingsTab, setSettingsOpen, hostBridge],
|
|
);
|
|
|
|
const [headerRef, headerBounds] = useMeasure();
|
|
const [footerRef, footerBounds] = useMeasure();
|
|
|
|
const gridBounds = useMemo(
|
|
() => ({
|
|
width: bounds.width,
|
|
height:
|
|
bounds.height -
|
|
(edgeToEdge ? 0 : headerBounds.height + footerBounds.height),
|
|
}),
|
|
[
|
|
bounds.width,
|
|
bounds.height,
|
|
headerBounds.height,
|
|
footerBounds.height,
|
|
edgeToEdge,
|
|
],
|
|
);
|
|
const gridBounds$ = useValueBehavior(gridBounds);
|
|
|
|
useAppBarHidden(!showHeader);
|
|
useAppBarSubtitle(
|
|
ringingVm && vm.ringingStatusLocation === "app_bar" && (
|
|
<RingingStatus vm={ringingVm} />
|
|
),
|
|
);
|
|
|
|
let header: ReactNode = null;
|
|
switch (headerStyle) {
|
|
case HeaderStyle.AppBar: {
|
|
// dont build a header here. The AppBar will take care of it.
|
|
break;
|
|
}
|
|
case HeaderStyle.None:
|
|
// Cosmetic header to fill out space while still affecting the bounds of
|
|
// the grid
|
|
header = showHeader && (
|
|
<div
|
|
className={classNames(styles.header, styles.filler)}
|
|
ref={headerRef}
|
|
/>
|
|
);
|
|
break;
|
|
case HeaderStyle.Standard:
|
|
header = (
|
|
<Header
|
|
className={classNames(styles.header, {
|
|
[styles.overlay]: edgeToEdge,
|
|
[styles.hidden]: !showHeader,
|
|
})}
|
|
ref={headerRef}
|
|
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
|
>
|
|
<LeftNav>
|
|
<RoomHeaderInfo
|
|
id={matrixInfo.roomId}
|
|
name={matrixInfo.roomName}
|
|
avatarUrl={matrixInfo.roomAvatar}
|
|
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
|
participantCount={participantCount}
|
|
/>
|
|
</LeftNav>
|
|
<RightNav>
|
|
{showControls && onShareClick !== null && (
|
|
<InviteButton className={styles.invite} onClick={onShareClick} />
|
|
)}
|
|
</RightNav>
|
|
</Header>
|
|
);
|
|
}
|
|
|
|
const onDismissScreenShareToast = useCallback(
|
|
() => vm.dismissScreenShareError(),
|
|
[vm],
|
|
);
|
|
const screenShareToast = (
|
|
<Toast
|
|
onDismiss={onDismissScreenShareToast}
|
|
open={screenShareError !== null}
|
|
autoDismiss={5000}
|
|
modal={false}
|
|
>
|
|
{t("error.screen_share_failed")}
|
|
</Toast>
|
|
);
|
|
|
|
// The reconnecting toast cannot be dismissed
|
|
const onDismissReconnectingToast = useCallback(() => {}, []);
|
|
// We need to use a non-modal toast to avoid trapping focus within the toast.
|
|
// However, a non-modal toast will not render any background overlay on its
|
|
// own, so we must render one manually.
|
|
const reconnectingToast = (
|
|
<>
|
|
<div
|
|
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
|
data-state={reconnecting ? "open" : "closed"}
|
|
/>
|
|
<Toast
|
|
onDismiss={onDismissReconnectingToast}
|
|
open={reconnecting}
|
|
modal={false}
|
|
>
|
|
{t("common.reconnecting")}
|
|
</Toast>
|
|
</>
|
|
);
|
|
|
|
const earpieceOverlay = (
|
|
<EarpieceOverlay
|
|
show={earpieceMode && !reconnecting}
|
|
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
|
/>
|
|
);
|
|
|
|
// If the reconnecting toast or earpiece overlay obscures the media tiles, we
|
|
// need to remove them from the accessibility tree and block focus.
|
|
const contentObscured = reconnecting || earpieceMode;
|
|
|
|
const Tile = useMemo(
|
|
() =>
|
|
function Tile({
|
|
ref,
|
|
className,
|
|
style,
|
|
targetWidth,
|
|
targetHeight,
|
|
model,
|
|
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
|
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
|
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
|
const showSpotlightIndicators = useBehavior(
|
|
vm.showSpotlightIndicators$,
|
|
);
|
|
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
|
|
const showNameTags = useBehavior(vm.showNameTags$);
|
|
const showRingingStatus = vm.ringingStatusLocation === "tile";
|
|
const showOutline = useBehavior(
|
|
model instanceof GridTileViewModel
|
|
? model.showOutline$
|
|
: constant(false),
|
|
);
|
|
|
|
return model instanceof GridTileViewModel ? (
|
|
<GridTile
|
|
ref={ref}
|
|
vm={model}
|
|
onOpenProfile={openProfile}
|
|
targetWidth={targetWidth}
|
|
targetHeight={targetHeight}
|
|
className={classNames(className, styles.tile)}
|
|
style={style}
|
|
showSpeakingIndicators={showSpeakingIndicators}
|
|
showNameTags={showNameTags}
|
|
showRingingStatus={showRingingStatus}
|
|
showOutline={showOutline}
|
|
focusable={!contentObscured}
|
|
/>
|
|
) : (
|
|
<SpotlightTile
|
|
ref={ref}
|
|
vm={model}
|
|
expanded={spotlightExpanded}
|
|
onToggleExpanded={onToggleExpanded}
|
|
targetWidth={targetWidth}
|
|
targetHeight={targetHeight}
|
|
showIndicators={showSpotlightIndicators}
|
|
showNameTags={showNameTags}
|
|
showRingingStatus={showRingingStatus}
|
|
focusable={!contentObscured}
|
|
className={classNames(className, styles.tile)}
|
|
itemClassName={styles.spotlightItem}
|
|
style={style}
|
|
/>
|
|
);
|
|
},
|
|
[vm, openProfile, contentObscured],
|
|
);
|
|
|
|
const layouts = useMemo(() => {
|
|
const inputs = { minBounds$: gridBounds$ };
|
|
return {
|
|
grid: makeGridLayout(inputs),
|
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
|
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
|
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
|
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
|
|
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
|
|
};
|
|
}, [gridBounds$]);
|
|
|
|
const showFooter = useBehavior(footerVm.showFooter$);
|
|
const renderContent = (): JSX.Element => {
|
|
if (layout.type === "pip") {
|
|
return (
|
|
<SpotlightTile
|
|
className={styles.tile}
|
|
itemClassName={styles.spotlightItem}
|
|
data-maximised
|
|
vm={layout.spotlight}
|
|
expanded
|
|
onToggleExpanded={null}
|
|
targetWidth={gridBounds.width}
|
|
targetHeight={gridBounds.height}
|
|
showIndicators={false}
|
|
showNameTags={showNameTags}
|
|
showRingingStatus={vm.ringingStatusLocation === "tile"}
|
|
focusable={!contentObscured}
|
|
aria-hidden={contentObscured}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
|
|
const fixedGrid = (
|
|
<Grid
|
|
key="fixed"
|
|
className={styles.fixedGrid}
|
|
style={{
|
|
// If not edge-to-edge, consume the header insets right here.
|
|
insetBlockStart: edgeToEdge ? 0 : topWithinRoot + headerBounds.height,
|
|
height: edgeToEdge ? "100%" : gridBounds.height,
|
|
// If edge-to-edge, compute new safe area insets that account for the
|
|
// header and footer, passing them down to the tiles.
|
|
"--call-view-safe-area-inset-top":
|
|
edgeToEdge && headerStyle !== HeaderStyle.None && showHeader
|
|
? // Header has two relevant cases: if it's an app bar, it lives
|
|
// outside the InCallView and consumes the safe area insets
|
|
// itself. Otherwise account for the safe area and header size
|
|
// as part of the InCallView.
|
|
headerStyle === HeaderStyle.AppBar
|
|
? `${topWithinRoot}px`
|
|
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
|
: undefined,
|
|
"--call-view-safe-area-inset-bottom":
|
|
edgeToEdge && showFooter
|
|
? // Footer always lives inside the InCallView.
|
|
`calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
|
|
: undefined,
|
|
}}
|
|
model={layout}
|
|
Layout={layers.fixed}
|
|
Tile={Tile}
|
|
aria-hidden={contentObscured}
|
|
/>
|
|
);
|
|
const scrollingGrid = (
|
|
<Grid
|
|
key="scrolling"
|
|
className={styles.scrollingGrid}
|
|
model={layout}
|
|
Layout={layers.scrolling}
|
|
Tile={Tile}
|
|
aria-hidden={contentObscured}
|
|
/>
|
|
);
|
|
|
|
// Put the right layer in the foreground for the requested layout
|
|
switch (layers.foreground) {
|
|
case "fixed":
|
|
return (
|
|
<>
|
|
{scrollingGrid}
|
|
{fixedGrid}
|
|
</>
|
|
);
|
|
case "scrolling":
|
|
return (
|
|
<>
|
|
{fixedGrid}
|
|
{scrollingGrid}
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
|
matrixRoom.roomId,
|
|
);
|
|
|
|
useAppBarSecondaryButton(
|
|
<SettingsIconButton
|
|
key="settings"
|
|
onClick={() => setSettingsOpen(true)}
|
|
data-testid="settings-app-bar"
|
|
/>,
|
|
);
|
|
|
|
// Only hide the settings button if we have an AppBar header and we are showing the header
|
|
const footer = footerVm !== null && (
|
|
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
|
|
);
|
|
const connectedTransports = useBehavior(vm.connectedTransports$);
|
|
const localMember = useBehavior(vm.localMember$);
|
|
const localTransport = useBehavior(
|
|
localMember?.transport$ ?? constant(undefined),
|
|
);
|
|
|
|
return (
|
|
// The pointer handler here exists to control the visibility of the footer,
|
|
// and the footer is also viewable by moving focus into it, so this is fine.
|
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
|
<div
|
|
className={classNames(styles.inRoom, {
|
|
[styles.overflowing]: overflowing,
|
|
})}
|
|
ref={containerRef}
|
|
// Which layout the call has settled on, for tests and for anyone
|
|
// wondering why the call looks the way it does at the size it was given
|
|
data-layout={layout.type}
|
|
onPointerUp={onViewPointerUp}
|
|
onPointerMove={onPointerMove}
|
|
onPointerOut={onPointerOut}
|
|
>
|
|
{header}
|
|
<MemberAudioRenderer members={remoteMembers} muted={muteAllAudio} />
|
|
{renderContent()}
|
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
|
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
|
|
{reconnectingToast}
|
|
{screenShareToast}
|
|
{earpieceOverlay}
|
|
<ReactionsOverlay vm={vm} />
|
|
{footer}
|
|
{showModals && (
|
|
<>
|
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
|
<SettingsModal
|
|
client={client}
|
|
roomId={matrixRoom.roomId}
|
|
open={settingsOpen}
|
|
onDismiss={(): void => setSettingsOpen(false)}
|
|
tab={settingsTab}
|
|
onTabChange={setSettingsTab}
|
|
developerSettingsVm={developerSettingsVm}
|
|
transports={connectedTransports.map((transport) => ({
|
|
transport,
|
|
local: transport === localTransport,
|
|
}))}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* How the tracks are captured and encoded, from Element Call's settings and
|
|
* configuration. Shared with the legacy SDK bundle so that a widget built from
|
|
* it behaves like the app.
|
|
*/
|
|
export function captureSettings(): CaptureSettings {
|
|
const config = Config.get();
|
|
const cameraSettings = advancedCamera.getValue()
|
|
? {
|
|
resolution: {
|
|
...parseResolution(cameraResolution.getValue()),
|
|
frameRate: cameraFramerate.getValue(),
|
|
},
|
|
maxBitrate: cameraBitrate.getValue(),
|
|
maxFramerate: cameraFramerate.getValue(),
|
|
codec: cameraCodec.getValue(),
|
|
}
|
|
: undefined;
|
|
const screenConfig = config.media_quality?.screen_share;
|
|
const screenShareSettings = advancedScreenShare.getValue()
|
|
? {
|
|
resolution: {
|
|
...parseResolution(screenShareResolution.getValue()),
|
|
frameRate: screenShareFramerate.getValue(),
|
|
},
|
|
maxBitrate: screenShareBitrate.getValue(),
|
|
maxFramerate: screenShareFramerate.getValue(),
|
|
codec: screenShareCodec.getValue(),
|
|
}
|
|
: screenConfig?.max_resolution
|
|
? {
|
|
resolution: {
|
|
width: Math.round((screenConfig.max_resolution * 16) / 9),
|
|
height: screenConfig.max_resolution,
|
|
frameRate: screenConfig.max_framerate ?? 30,
|
|
},
|
|
}
|
|
: undefined;
|
|
return {
|
|
audio: {
|
|
echoCancellation: echoCancellationSetting.getValue(),
|
|
noiseSuppression: noiseSuppressionSetting.getValue(),
|
|
autoGainControl: autoGainControlSetting.getValue(),
|
|
},
|
|
camera: cameraSettings,
|
|
screenShare: screenShareSettings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What the client needs from Element Call's configuration, settings and URL:
|
|
* the SDK reads none of them itself. What to publish depends on the mute
|
|
* switches and the devices, so the caller adds that. Shared with the legacy
|
|
* SDK bundle so that a widget built from it behaves like the app.
|
|
*/
|
|
export function rtcSlotOptions(
|
|
encryptionSystem: EncryptionSystem,
|
|
): RTCSlotOptions {
|
|
return {
|
|
encryptionSystem,
|
|
// matrix_rtc_mode in config.json overrides the user's Developer Settings
|
|
// choice. We merely sample the current mode here, so the user would need
|
|
// to manually rejoin to switch to a different one.
|
|
matrixRTCMode:
|
|
(Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
|
|
matrixRTCModeSetting.value$.value,
|
|
};
|
|
}
|
|
|
|
export function rtcParticipationOptions(
|
|
urlParams: ReturnType<typeof useUrlParams>,
|
|
): Omit<RTCParticipationOptions, "publish"> {
|
|
const config = Config.get();
|
|
const session = config.matrix_rtc_session;
|
|
return {
|
|
sendNotificationType: urlParams.sendNotificationType,
|
|
applicationData: urlParams.callIntent
|
|
? { "m.call.intent": urlParams.callIntent }
|
|
: undefined,
|
|
timings: {
|
|
syncDisconnectGracePeriodMs: config.sync_disconnect_grace_period_ms,
|
|
networkErrorRetryMs: session.network_error_retry_ms,
|
|
membershipEventExpiryMs: session.membership_event_expiry_ms,
|
|
keyRotationParticipantLimit: session.key_rotation_participant_limit,
|
|
delayedLeave: session.delayed_leave,
|
|
delegatedDelayedLeave: session.delegated_delayed_leave,
|
|
},
|
|
mediaQuality: config.media_quality,
|
|
transport: customTransport.value$.value ?? undefined,
|
|
fallbackTransport: config.livekit && {
|
|
type: "livekit",
|
|
livekit_service_url: config.livekit.livekit_service_url,
|
|
},
|
|
};
|
|
}
|