2022-05-04 17:09:48 +01:00
|
|
|
/*
|
2024-09-06 10:22:13 +02:00
|
|
|
Copyright 2022-2024 New Vector Ltd.
|
2022-05-04 17:09:48 +01:00
|
|
|
|
2025-02-18 17:59:58 +00:00
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
2024-09-06 10:22:13 +02:00
|
|
|
Please see LICENSE in the repository root for full details.
|
2022-05-04 17:09:48 +01:00
|
|
|
*/
|
|
|
|
|
|
2025-06-26 05:08:57 -04:00
|
|
|
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
2025-08-15 18:32:37 +02:00
|
|
|
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
2023-09-22 18:05:13 -04:00
|
|
|
import {
|
2024-12-11 09:27:55 +00:00
|
|
|
type FC,
|
|
|
|
|
type PointerEvent,
|
|
|
|
|
type TouchEvent,
|
2023-09-22 18:05:13 -04:00
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
2025-01-13 14:54:42 +00:00
|
|
|
type JSX,
|
2025-06-23 22:48:37 -04:00
|
|
|
type ReactNode,
|
2023-09-22 18:05:13 -04:00
|
|
|
} from "react";
|
2023-06-05 20:51:01 +02:00
|
|
|
import useMeasure from "react-use-measure";
|
2025-03-13 13:58:43 +01:00
|
|
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
2023-10-26 19:33:59 +02:00
|
|
|
import classNames from "classnames";
|
2025-05-16 11:32:32 +02:00
|
|
|
import { BehaviorSubject, map } from "rxjs";
|
2025-09-16 16:52:17 +02:00
|
|
|
import { useObservable, useObservableEagerState } from "observable-hooks";
|
2025-03-13 13:58:43 +01:00
|
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
2025-04-11 17:05:57 +02:00
|
|
|
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
2025-06-26 05:08:57 -04:00
|
|
|
import {
|
2025-07-18 15:19:53 +02:00
|
|
|
VoiceCallSolidIcon,
|
2025-06-26 05:08:57 -04:00
|
|
|
VolumeOnSolidIcon,
|
|
|
|
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
2025-08-27 14:01:01 +02:00
|
|
|
import { ConnectionState } from "livekit-client";
|
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";
|
2025-03-13 18:15:58 +01:00
|
|
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
2022-01-05 15:06:51 -08:00
|
|
|
import {
|
2024-08-28 08:44:39 -04:00
|
|
|
EndCallButton,
|
2022-01-05 15:06:51 -08:00
|
|
|
MicButton,
|
|
|
|
|
VideoButton,
|
2024-08-28 08:44:39 -04:00
|
|
|
ShareScreenButton,
|
2023-05-05 11:44:35 +02:00
|
|
|
SettingsButton,
|
2024-11-08 17:36:40 +00:00
|
|
|
ReactionToggleButton,
|
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";
|
2025-06-26 05:08:57 -04:00
|
|
|
import { type HeaderStyle, 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";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { type 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";
|
2023-07-21 00:51:07 -04:00
|
|
|
import { useWakeLock } from "../useWakeLock";
|
2023-08-02 15:29:37 -04:00
|
|
|
import { useMergedRefs } from "../useMergedRefs";
|
2025-08-29 18:46:24 +02:00
|
|
|
import { type MuteStates } from "../state/MuteStates";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { type MatrixInfo } from "./VideoPreview";
|
2023-09-27 15:17:04 -04:00
|
|
|
import { InviteButton } from "../button/InviteButton";
|
2023-09-08 15:39:10 -04:00
|
|
|
import { LayoutToggle } from "./LayoutToggle";
|
2024-12-11 09:27:55 +00:00
|
|
|
import {
|
|
|
|
|
CallViewModel,
|
|
|
|
|
type GridMode,
|
|
|
|
|
type Layout,
|
|
|
|
|
} from "../state/CallViewModel";
|
|
|
|
|
import { Grid, type TileProps } from "../grid/Grid";
|
2024-05-02 18:44:36 -04:00
|
|
|
import { useInitial } from "../useInitial";
|
|
|
|
|
import { SpotlightTile } from "../tile/SpotlightTile";
|
2025-05-13 22:05:55 +02:00
|
|
|
import {
|
|
|
|
|
useRoomEncryptionSystem,
|
|
|
|
|
type EncryptionSystem,
|
|
|
|
|
} from "../e2ee/sharedKeyManagement";
|
2024-04-23 15:15:13 +02:00
|
|
|
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 {
|
2024-12-11 09:27:55 +00:00
|
|
|
type CallLayoutOutputs,
|
2024-06-07 16:59:56 -04:00
|
|
|
defaultPipAlignment,
|
|
|
|
|
defaultSpotlightAlignment,
|
|
|
|
|
} from "../grid/CallLayout";
|
|
|
|
|
import { makeOneOnOneLayout } from "../grid/OneOnOneLayout";
|
2024-07-03 15:08:30 -04:00
|
|
|
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
|
|
|
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
|
|
|
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
2024-12-11 09:27:55 +00:00
|
|
|
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
2024-12-19 15:54:28 +00:00
|
|
|
import {
|
|
|
|
|
ReactionsSenderProvider,
|
|
|
|
|
useReactionsSender,
|
|
|
|
|
} from "../reactions/useReactionsSender";
|
2024-11-08 17:36:40 +00:00
|
|
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
2024-11-11 12:07:02 +00:00
|
|
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
2025-09-16 16:52:17 +02:00
|
|
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
2024-12-11 05:23:42 -05:00
|
|
|
import {
|
|
|
|
|
debugTileLayout as debugTileLayoutSetting,
|
2025-05-13 21:11:12 +02:00
|
|
|
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
2025-05-13 22:22:56 +02:00
|
|
|
developerMode as developerModeSetting,
|
2024-12-11 05:23:42 -05:00
|
|
|
useSetting,
|
|
|
|
|
} from "../settings/settings";
|
2024-12-19 15:54:28 +00:00
|
|
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
2025-04-11 17:05:57 +02:00
|
|
|
import { useTypedEventEmitter } from "../useEvents.ts";
|
2025-09-16 11:31:47 +02:00
|
|
|
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
2025-05-16 11:32:32 +02:00
|
|
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
2025-06-26 05:08:57 -04:00
|
|
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
|
|
|
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
|
|
|
|
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
2025-06-18 18:33:35 -04:00
|
|
|
import { useBehavior } from "../useBehavior.ts";
|
2025-08-15 18:38:52 +02:00
|
|
|
import { Toast } from "../Toast.tsx";
|
2025-09-18 12:58:47 +02:00
|
|
|
import overlayStyles from "../Overlay.module.css";
|
2025-09-15 09:58:16 +02:00
|
|
|
import { Avatar, Size as AvatarSize } from "../Avatar";
|
|
|
|
|
import waitingStyles from "./WaitingForJoin.module.css";
|
|
|
|
|
import { prefetchSounds } from "../soundUtils";
|
|
|
|
|
import { useAudioContext } from "../useAudioContext";
|
2025-09-15 15:41:15 +01:00
|
|
|
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
|
|
|
|
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
2025-08-27 14:01:01 +02:00
|
|
|
import { ConnectionLostError } from "../utils/errors.ts";
|
2025-09-23 11:38:34 +02:00
|
|
|
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
2025-04-11 17:05:57 +02:00
|
|
|
|
2024-08-08 17:21:47 -04:00
|
|
|
const maxTapDurationMs = 400;
|
|
|
|
|
|
2023-11-28 19:07:08 +01:00
|
|
|
export interface ActiveCallProps
|
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
|
|
|
extends Omit<InCallViewProps, "vm" | "livekitRoom" | "connState"> {
|
2024-04-23 15:15:13 +02:00
|
|
|
e2eeSystem: EncryptionSystem;
|
2025-09-16 16:52:17 +02:00
|
|
|
// TODO refactor those reasons into an enum
|
|
|
|
|
onLeft: (reason: "user" | "timeout" | "decline" | "allOthersLeft") => void;
|
2023-06-16 18:07:13 +02:00
|
|
|
}
|
|
|
|
|
|
2023-09-22 18:05:13 -04:00
|
|
|
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
2025-06-26 05:08:57 -04:00
|
|
|
const mediaDevices = useMediaDevices();
|
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
|
|
|
const [vm, setVm] = useState<CallViewModel | null>(null);
|
2023-11-28 19:07:08 +01:00
|
|
|
|
2025-08-27 14:01:01 +02:00
|
|
|
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
|
|
|
|
useUrlParams();
|
|
|
|
|
|
2025-09-23 11:38:34 +02:00
|
|
|
const trackProcessorState$ = useTrackProcessorObservable$();
|
2023-11-28 19:07:08 +01:00
|
|
|
useEffect(() => {
|
2025-08-27 14:01:01 +02:00
|
|
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
|
|
|
|
const vm = new CallViewModel(
|
|
|
|
|
props.rtcSession,
|
|
|
|
|
props.matrixRoom,
|
|
|
|
|
mediaDevices,
|
2025-08-29 18:46:24 +02:00
|
|
|
props.muteStates,
|
2025-08-27 14:01:01 +02:00
|
|
|
{
|
|
|
|
|
encryptionSystem: props.e2eeSystem,
|
|
|
|
|
autoLeaveWhenOthersLeft,
|
|
|
|
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
|
|
|
|
},
|
|
|
|
|
reactionsReader.raisedHands$,
|
|
|
|
|
reactionsReader.reactions$,
|
2025-09-23 11:38:34 +02:00
|
|
|
trackProcessorState$,
|
2025-05-14 18:41:22 +02:00
|
|
|
);
|
2025-08-27 14:01:01 +02:00
|
|
|
setVm(vm);
|
2025-09-16 16:52:17 +02:00
|
|
|
|
2025-09-24 21:26:16 -04:00
|
|
|
const sub = vm.leave$.subscribe(props.onLeft);
|
2024-06-04 11:20:25 -04:00
|
|
|
return (): void => {
|
2025-08-27 14:01:01 +02:00
|
|
|
vm.destroy();
|
2025-09-16 16:52:17 +02:00
|
|
|
sub.unsubscribe();
|
2025-08-27 14:01:01 +02:00
|
|
|
reactionsReader.destroy();
|
2023-11-28 19:07:08 +01:00
|
|
|
};
|
2025-06-26 05:08:57 -04:00
|
|
|
}, [
|
|
|
|
|
props.rtcSession,
|
2025-08-15 18:32:37 +02:00
|
|
|
props.matrixRoom,
|
2025-06-26 05:08:57 -04:00
|
|
|
mediaDevices,
|
2025-08-29 18:46:24 +02:00
|
|
|
props.muteStates,
|
2025-06-26 05:08:57 -04:00
|
|
|
props.e2eeSystem,
|
2025-08-08 17:15:47 +02:00
|
|
|
autoLeaveWhenOthersLeft,
|
2025-09-05 14:36:27 +02:00
|
|
|
sendNotificationType,
|
|
|
|
|
waitForCallPickup,
|
2025-09-16 16:52:17 +02:00
|
|
|
props.onLeft,
|
2025-09-23 11:38:34 +02:00
|
|
|
trackProcessorState$,
|
2025-06-26 05:08:57 -04:00
|
|
|
]);
|
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
|
|
|
|
2025-08-27 14:01:01 +02:00
|
|
|
if (vm === null) return null;
|
2023-06-30 16:43:28 +01:00
|
|
|
|
2023-06-26 13:48:41 -04:00
|
|
|
return (
|
2025-08-27 14:01:01 +02:00
|
|
|
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
|
|
|
|
<InCallView {...props} vm={vm} />
|
|
|
|
|
</ReactionsSenderProvider>
|
2023-06-26 13:48:41 -04:00
|
|
|
);
|
2023-09-22 18:05:13 -04:00
|
|
|
};
|
2023-06-12 15:35:54 +02:00
|
|
|
|
2023-07-05 13:12:37 +01:00
|
|
|
export interface InCallViewProps {
|
2022-08-02 00:46:16 +02:00
|
|
|
client: MatrixClient;
|
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
|
|
|
vm: CallViewModel;
|
2023-09-08 15:39:10 -04:00
|
|
|
matrixInfo: MatrixInfo;
|
2023-08-16 18:41:27 +01:00
|
|
|
rtcSession: MatrixRTCSession;
|
2025-08-15 18:32:37 +02:00
|
|
|
matrixRoom: MatrixRoom;
|
2023-08-02 15:29:37 -04:00
|
|
|
muteStates: MuteStates;
|
2025-06-26 05:08:57 -04:00
|
|
|
header: HeaderStyle;
|
2023-06-30 16:43:28 +01:00
|
|
|
otelGroupCallMembership?: OTelGroupCallMembership;
|
2023-09-08 15:39:10 -04:00
|
|
|
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,
|
Add simple global controls to put the call in picture-in-picture mode (#2573)
* Stop sharing state observables when the view model is destroyed
By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior.
* Hydrate the call view model in a less hacky way
This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix.
* Add simple global controls to put the call in picture-in-picture mode
Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…)
To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only.
* Fix footer appearing in large PiP views
* Add a method for whether you can enter picture-in-picture mode
* Have the controls emit booleans directly
2024-08-27 07:47:20 -04:00
|
|
|
vm,
|
2024-05-16 15:23:10 -04:00
|
|
|
matrixInfo,
|
|
|
|
|
rtcSession,
|
2025-08-15 18:32:37 +02:00
|
|
|
matrixRoom,
|
2024-05-16 15:23:10 -04:00
|
|
|
muteStates,
|
2025-09-16 16:52:17 +02:00
|
|
|
|
2025-06-26 05:08:57 -04:00
|
|
|
header: headerStyle,
|
2024-05-16 15:23:10 -04:00
|
|
|
onShareClick,
|
|
|
|
|
}) => {
|
2025-06-26 05:08:57 -04:00
|
|
|
const { t } = useTranslation();
|
2024-12-19 15:54:28 +00:00
|
|
|
const { supportsReactions, sendReaction, toggleRaisedHand } =
|
|
|
|
|
useReactionsSender();
|
2024-11-04 08:54:13 -01:00
|
|
|
|
2024-05-16 15:23:10 -04:00
|
|
|
useWakeLock();
|
2025-09-16 14:16:11 +01:00
|
|
|
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
2024-05-16 15:23:10 -04:00
|
|
|
|
2025-02-26 17:20:30 +07:00
|
|
|
// annoyingly we don't get the disconnection reason this way,
|
|
|
|
|
// only by listening for the emitted event
|
2025-09-17 11:25:49 +02:00
|
|
|
// This needs to be done differential. with the vm connection state we start with Disconnected.
|
2025-09-19 18:01:45 +02:00
|
|
|
// TODO-MULTI-SFU decide how to handle this properly
|
2025-09-22 14:17:38 +02:00
|
|
|
// @BillCarsonFr
|
2025-09-17 11:25:49 +02:00
|
|
|
// if (connectionState === ConnectionState.Disconnected)
|
|
|
|
|
// throw new ConnectionLostError();
|
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();
|
|
|
|
|
// Merge the refs so they can attach to the same element
|
|
|
|
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
2023-06-02 14:49:11 +02:00
|
|
|
|
2025-09-24 13:54:54 -04:00
|
|
|
const { showControls } = useUrlParams();
|
2024-05-16 15:23:10 -04:00
|
|
|
|
2025-06-18 18:33:35 -04:00
|
|
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
2025-09-15 09:58:16 +02:00
|
|
|
// Call pickup state and display names are needed for waiting overlay/sounds
|
|
|
|
|
const callPickupState = useBehavior(vm.callPickupState$);
|
|
|
|
|
|
|
|
|
|
// Preload a waiting and decline sounds
|
|
|
|
|
const pickupPhaseSoundCache = useInitial(async () => {
|
|
|
|
|
return prefetchSounds({
|
2025-09-15 15:41:15 +01:00
|
|
|
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
|
2025-09-15 09:58:16 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const pickupPhaseAudio = useAudioContext({
|
|
|
|
|
sounds: pickupPhaseSoundCache,
|
|
|
|
|
latencyHint: "interactive",
|
|
|
|
|
muted: muteAllAudio,
|
|
|
|
|
});
|
2025-05-13 21:11:12 +02:00
|
|
|
|
2025-05-13 22:05:55 +02:00
|
|
|
// This seems like it might be enough logic to use move it into the call view model?
|
|
|
|
|
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
2025-04-11 17:05:57 +02:00
|
|
|
useTypedEventEmitter(
|
|
|
|
|
rtcSession,
|
|
|
|
|
RoomAndToDeviceEvents.EnabledTransportsChanged,
|
2025-05-13 22:05:55 +02:00
|
|
|
(enabled) => setDidFallbackToRoomKey(enabled.room),
|
|
|
|
|
);
|
2025-05-13 22:22:56 +02:00
|
|
|
|
|
|
|
|
const [developerMode] = useSetting(developerModeSetting);
|
2025-05-13 22:05:55 +02:00
|
|
|
const [useExperimentalToDeviceTransport] = useSetting(
|
|
|
|
|
useExperimentalToDeviceTransportSetting,
|
|
|
|
|
);
|
2025-08-15 18:32:37 +02:00
|
|
|
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
2025-05-13 22:05:55 +02:00
|
|
|
|
|
|
|
|
const showToDeviceEncryption = useMemo(
|
|
|
|
|
() =>
|
2025-05-13 22:22:56 +02:00
|
|
|
developerMode &&
|
2025-05-13 22:05:55 +02:00
|
|
|
useExperimentalToDeviceTransport &&
|
|
|
|
|
encryptionSystem.kind === E2eeType.PER_PARTICIPANT &&
|
|
|
|
|
!didFallbackToRoomKey,
|
|
|
|
|
[
|
2025-05-13 22:22:56 +02:00
|
|
|
developerMode,
|
|
|
|
|
useExperimentalToDeviceTransport,
|
2025-05-13 22:05:55 +02:00
|
|
|
encryptionSystem.kind,
|
|
|
|
|
didFallbackToRoomKey,
|
|
|
|
|
],
|
2025-04-11 17:05:57 +02:00
|
|
|
);
|
2025-04-11 10:07:50 +02:00
|
|
|
|
2025-08-29 18:46:24 +02:00
|
|
|
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
|
|
|
|
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
|
|
|
|
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
|
|
|
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
|
|
|
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
2024-05-16 15:23:10 -04:00
|
|
|
|
|
|
|
|
// 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,
|
2025-08-29 18:46:24 +02:00
|
|
|
toggleAudio,
|
|
|
|
|
toggleVideo,
|
|
|
|
|
setAudioEnabled,
|
2024-11-19 16:57:57 +00:00
|
|
|
(reaction) => void sendReaction(reaction),
|
|
|
|
|
() => void toggleRaisedHand(),
|
2024-05-16 15:23:10 -04:00
|
|
|
);
|
2023-01-12 17:31:19 +00:00
|
|
|
|
2025-08-25 13:49:01 +02:00
|
|
|
const participantCount = useBehavior(vm.participantCount$);
|
2025-08-15 18:38:52 +02:00
|
|
|
const reconnecting = useBehavior(vm.reconnecting$);
|
2025-06-18 18:33:35 -04:00
|
|
|
const windowMode = useBehavior(vm.windowMode$);
|
|
|
|
|
const layout = useBehavior(vm.layout$);
|
|
|
|
|
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
2024-12-11 05:23:42 -05:00
|
|
|
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
2025-06-18 18:33:35 -04:00
|
|
|
const gridMode = useBehavior(vm.gridMode$);
|
|
|
|
|
const showHeader = useBehavior(vm.showHeader$);
|
|
|
|
|
const showFooter = useBehavior(vm.showFooter$);
|
|
|
|
|
const earpieceMode = useBehavior(vm.earpieceMode$);
|
|
|
|
|
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
2025-09-24 13:54:54 -04:00
|
|
|
const sharingScreen = useBehavior(vm.sharingScreen$);
|
2025-09-15 15:41:15 +01:00
|
|
|
|
|
|
|
|
// We need to set the proper timings on the animation based upon the sound length.
|
|
|
|
|
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
|
|
|
|
useEffect((): (() => void) => {
|
|
|
|
|
// The CSS animation includes the delay, so we must double the length of the sound.
|
|
|
|
|
window.document.body.style.setProperty(
|
|
|
|
|
"--call-ring-duration-s",
|
|
|
|
|
`${ringDuration * 2}s`,
|
|
|
|
|
);
|
|
|
|
|
window.document.body.style.setProperty(
|
|
|
|
|
"--call-ring-delay-s",
|
|
|
|
|
`${ringDuration}s`,
|
|
|
|
|
);
|
|
|
|
|
// Remove properties when we unload.
|
|
|
|
|
return () => {
|
|
|
|
|
window.document.body.style.removeProperty("--call-ring-duration-s");
|
|
|
|
|
window.document.body.style.removeProperty("--call-ring-delay-s");
|
|
|
|
|
};
|
|
|
|
|
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
|
2024-08-08 17:21:47 -04:00
|
|
|
|
2025-09-15 09:58:16 +02:00
|
|
|
// When waiting for pickup, loop a waiting sound
|
|
|
|
|
useEffect((): void | (() => void) => {
|
2025-09-15 15:41:15 +01:00
|
|
|
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
|
|
|
|
|
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
|
|
|
|
|
return () => {
|
|
|
|
|
void endSound().catch((e) => {
|
|
|
|
|
logger.error("Failed to stop ringing sound", e);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
}, [callPickupState, pickupPhaseAudio, ringDuration]);
|
2025-09-15 09:58:16 +02:00
|
|
|
|
|
|
|
|
// Waiting UI overlay
|
|
|
|
|
const waitingOverlay: JSX.Element | null = useMemo(() => {
|
|
|
|
|
// No overlay if not in ringing state
|
|
|
|
|
if (callPickupState !== "ringing") return null;
|
|
|
|
|
|
|
|
|
|
// Use room state for other participants data (the one that we likely want to reach)
|
2025-09-16 16:52:17 +02:00
|
|
|
// TODO: this screams it wants to be a behavior in the vm.
|
2025-09-15 09:58:16 +02:00
|
|
|
const roomOthers = [
|
|
|
|
|
...matrixRoom.getMembersWithMembership("join"),
|
|
|
|
|
...matrixRoom.getMembersWithMembership("invite"),
|
|
|
|
|
].filter((m) => m.userId !== client.getUserId());
|
|
|
|
|
// Yield if there are not other members in the room.
|
|
|
|
|
if (roomOthers.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined;
|
|
|
|
|
const isOneOnOne = roomOthers.length === 1 && otherMember;
|
|
|
|
|
const text = isOneOnOne
|
|
|
|
|
? `Waiting for ${otherMember.name ?? otherMember.userId} to join…`
|
|
|
|
|
: "Waiting for other participants…";
|
|
|
|
|
const avatarMxc = isOneOnOne
|
|
|
|
|
? (otherMember.getMxcAvatarUrl?.() ?? undefined)
|
|
|
|
|
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
|
|
|
|
|
<div
|
|
|
|
|
className={classNames(overlayStyles.content, waitingStyles.content)}
|
|
|
|
|
>
|
|
|
|
|
<div className={waitingStyles.pulse}>
|
|
|
|
|
<Avatar
|
|
|
|
|
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
|
|
|
|
|
name={isOneOnOne ? otherMember.name : matrixRoom.name}
|
|
|
|
|
src={avatarMxc}
|
|
|
|
|
size={AvatarSize.XL}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Text size="md" className={waitingStyles.text}>
|
|
|
|
|
{text}
|
|
|
|
|
</Text>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}, [callPickupState, client, matrixRoom]);
|
|
|
|
|
|
2024-08-08 17:21:47 -04:00
|
|
|
// 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), []);
|
|
|
|
|
|
2024-11-08 10:23:19 -05:00
|
|
|
// We also need to tell the footer controls to prevent touch events from
|
|
|
|
|
// bubbling up, or else the footer will be dismissed before a click/change
|
|
|
|
|
// event can be registered on the control
|
|
|
|
|
const onControlsTouchEnd = useCallback(
|
|
|
|
|
(e: TouchEvent) => {
|
|
|
|
|
// Somehow applying pointer-events: none to the controls when the footer
|
|
|
|
|
// is hidden is not enough to stop clicks from happening as the footer
|
|
|
|
|
// becomes visible, so we check manually whether the footer is shown
|
|
|
|
|
if (showFooter) {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
vm.tapControls();
|
|
|
|
|
} else {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[vm, showFooter],
|
2024-08-08 17:21:47 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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-10-28 15:15:02 -04:00
|
|
|
const openProfile = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
// Profile settings are unavailable in widget mode
|
|
|
|
|
widget === null
|
|
|
|
|
? (): void => {
|
|
|
|
|
setSettingsTab("profile");
|
|
|
|
|
setSettingsModalOpen(true);
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
[setSettingsTab, setSettingsModalOpen],
|
|
|
|
|
);
|
2024-05-16 15:23:10 -04:00
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
() => ({
|
2024-07-03 15:08:30 -04:00
|
|
|
width: bounds.width,
|
|
|
|
|
height:
|
|
|
|
|
bounds.height -
|
|
|
|
|
headerBounds.height -
|
|
|
|
|
(windowMode === "flat" ? 0 : footerBounds.height),
|
2024-05-16 15:23:10 -04:00
|
|
|
}),
|
|
|
|
|
[
|
2024-07-03 15:08:30 -04:00
|
|
|
bounds.width,
|
2024-05-16 15:23:10 -04:00
|
|
|
bounds.height,
|
|
|
|
|
headerBounds.height,
|
|
|
|
|
footerBounds.height,
|
2024-07-03 15:08:30 -04:00
|
|
|
windowMode,
|
2024-05-16 15:23:10 -04:00
|
|
|
],
|
|
|
|
|
);
|
2024-12-17 04:01:56 +00:00
|
|
|
const gridBoundsObservable$ = useObservable(
|
|
|
|
|
(inputs$) => inputs$.pipe(map(([gridBounds]) => gridBounds)),
|
2024-11-06 04:43:27 -05:00
|
|
|
[gridBounds],
|
|
|
|
|
);
|
2024-05-17 16:38:00 -04:00
|
|
|
|
2024-12-17 04:01:56 +00:00
|
|
|
const spotlightAlignment$ = useInitial(
|
2024-06-07 16:59:56 -04:00
|
|
|
() => new BehaviorSubject(defaultSpotlightAlignment),
|
|
|
|
|
);
|
2024-12-17 04:01:56 +00:00
|
|
|
const pipAlignment$ = useInitial(
|
2024-06-07 16:59:56 -04:00
|
|
|
() => 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(() => {
|
2024-09-10 09:49:35 +02:00
|
|
|
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]);
|
|
|
|
|
|
2025-06-26 05:08:57 -04:00
|
|
|
useAppBarSecondaryButton(
|
|
|
|
|
useMemo(() => {
|
|
|
|
|
if (audioOutputSwitcher === null) return null;
|
|
|
|
|
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
2025-07-18 15:19:53 +02:00
|
|
|
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
|
2025-06-26 05:08:57 -04:00
|
|
|
const label = isEarpieceTarget
|
2025-07-18 15:19:53 +02:00
|
|
|
? t("settings.devices.handset")
|
2025-06-26 05:08:57 -04:00
|
|
|
: t("settings.devices.loudspeaker");
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Tooltip label={label}>
|
|
|
|
|
<IconButton
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
audioOutputSwitcher.switch();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
);
|
|
|
|
|
}, [t, audioOutputSwitcher]),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useAppBarHidden(!showHeader);
|
|
|
|
|
|
|
|
|
|
let header: ReactNode = null;
|
|
|
|
|
if (showHeader) {
|
|
|
|
|
switch (headerStyle) {
|
|
|
|
|
case "none":
|
|
|
|
|
// Cosmetic header to fill out space while still affecting the bounds of
|
|
|
|
|
// the grid
|
|
|
|
|
header = (
|
|
|
|
|
<div
|
|
|
|
|
className={classNames(styles.header, styles.filler)}
|
|
|
|
|
ref={headerRef}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case "standard":
|
|
|
|
|
header = (
|
2025-08-20 13:30:21 +02:00
|
|
|
<Header
|
|
|
|
|
className={styles.header}
|
|
|
|
|
ref={headerRef}
|
|
|
|
|
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
|
|
|
|
>
|
2025-06-26 05:08:57 -04:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 12:58:47 +02:00
|
|
|
// 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;
|
|
|
|
|
|
2024-05-30 13:06:24 -04:00
|
|
|
const Tile = useMemo(
|
2024-05-16 15:23:10 -04:00
|
|
|
() =>
|
2025-06-23 22:48:37 -04:00
|
|
|
function Tile({
|
2024-05-30 13:06:24 -04:00
|
|
|
ref,
|
2025-06-23 22:48:37 -04:00
|
|
|
className,
|
|
|
|
|
style,
|
|
|
|
|
targetWidth,
|
|
|
|
|
targetHeight,
|
|
|
|
|
model,
|
|
|
|
|
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
2025-06-18 18:33:35 -04:00
|
|
|
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
|
|
|
|
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
|
|
|
|
const showSpeakingIndicatorsValue = useBehavior(
|
2024-12-17 04:01:56 +00:00
|
|
|
vm.showSpeakingIndicators$,
|
2024-05-30 13:06:24 -04:00
|
|
|
);
|
2025-06-18 18:33:35 -04:00
|
|
|
const showSpotlightIndicatorsValue = useBehavior(
|
2024-12-17 04:01:56 +00:00
|
|
|
vm.showSpotlightIndicators$,
|
2024-05-30 13:06:24 -04:00
|
|
|
);
|
|
|
|
|
|
2024-11-06 04:36:48 -05:00
|
|
|
return model instanceof GridTileViewModel ? (
|
2024-05-30 13:06:24 -04:00
|
|
|
<GridTile
|
|
|
|
|
ref={ref}
|
2024-11-06 04:36:48 -05:00
|
|
|
vm={model}
|
2024-05-30 13:06:24 -04:00
|
|
|
onOpenProfile={openProfile}
|
|
|
|
|
targetWidth={targetWidth}
|
|
|
|
|
targetHeight={targetHeight}
|
|
|
|
|
className={classNames(className, styles.tile)}
|
|
|
|
|
style={style}
|
|
|
|
|
showSpeakingIndicators={showSpeakingIndicatorsValue}
|
2025-09-18 12:58:47 +02:00
|
|
|
focusable={!contentObscured}
|
2024-05-30 13:06:24 -04:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<SpotlightTile
|
|
|
|
|
ref={ref}
|
2024-11-06 04:36:48 -05:00
|
|
|
vm={model}
|
2024-07-03 15:08:30 -04:00
|
|
|
expanded={spotlightExpanded}
|
2024-07-26 06:57:49 -04:00
|
|
|
onToggleExpanded={onToggleExpanded}
|
2024-05-30 13:06:24 -04:00
|
|
|
targetWidth={targetWidth}
|
|
|
|
|
targetHeight={targetHeight}
|
|
|
|
|
showIndicators={showSpotlightIndicatorsValue}
|
2025-09-18 12:58:47 +02:00
|
|
|
focusable={!contentObscured}
|
2024-05-30 13:06:24 -04:00
|
|
|
className={classNames(className, styles.tile)}
|
|
|
|
|
style={style}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2025-06-23 22:48:37 -04:00
|
|
|
},
|
2025-09-18 12:58:47 +02:00
|
|
|
[vm, openProfile, contentObscured],
|
2024-05-16 15:23:10 -04:00
|
|
|
);
|
2024-05-30 13:06:24 -04:00
|
|
|
|
2024-07-03 15:08:30 -04:00
|
|
|
const layouts = useMemo(() => {
|
|
|
|
|
const inputs = {
|
2024-12-17 04:01:56 +00:00
|
|
|
minBounds$: gridBoundsObservable$,
|
|
|
|
|
spotlightAlignment$,
|
|
|
|
|
pipAlignment$,
|
2024-07-03 15:08:30 -04:00
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
grid: makeGridLayout(inputs),
|
2024-07-18 11:24:18 -04:00
|
|
|
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
|
|
|
|
|
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
|
|
|
|
|
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
|
2024-07-03 15:08:30 -04:00
|
|
|
"one-on-one": makeOneOnOneLayout(inputs),
|
|
|
|
|
};
|
2024-12-17 04:01:56 +00:00
|
|
|
}, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]);
|
2024-07-03 15:08:30 -04:00
|
|
|
|
2024-05-16 15:23:10 -04:00
|
|
|
const renderContent = (): JSX.Element => {
|
2024-07-03 15:08:30 -04:00
|
|
|
if (layout.type === "pip") {
|
|
|
|
|
return (
|
|
|
|
|
<SpotlightTile
|
|
|
|
|
className={classNames(styles.tile, styles.maximised)}
|
2024-11-06 04:36:48 -05:00
|
|
|
vm={layout.spotlight}
|
2024-07-03 15:08:30 -04:00
|
|
|
expanded
|
|
|
|
|
onToggleExpanded={null}
|
|
|
|
|
targetWidth={gridBounds.height}
|
|
|
|
|
targetHeight={gridBounds.width}
|
|
|
|
|
showIndicators={false}
|
2025-09-18 12:58:47 +02:00
|
|
|
focusable={!contentObscured}
|
|
|
|
|
aria-hidden={contentObscured}
|
2024-07-03 15:08:30 -04:00
|
|
|
/>
|
|
|
|
|
);
|
2024-05-16 15:23:10 -04:00
|
|
|
}
|
2022-12-13 18:20:13 -05:00
|
|
|
|
2024-07-03 15:08:30 -04:00
|
|
|
const layers = layouts[layout.type] as CallLayoutOutputs<Layout>;
|
|
|
|
|
const fixedGrid = (
|
|
|
|
|
<Grid
|
|
|
|
|
key="fixed"
|
|
|
|
|
className={styles.fixedGrid}
|
|
|
|
|
style={{
|
2025-06-26 05:08:57 -04:00
|
|
|
insetBlockStart:
|
|
|
|
|
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
|
2024-07-03 15:08:30 -04:00
|
|
|
height: gridBounds.height,
|
|
|
|
|
}}
|
|
|
|
|
model={layout}
|
|
|
|
|
Layout={layers.fixed}
|
|
|
|
|
Tile={Tile}
|
2025-09-18 12:58:47 +02:00
|
|
|
aria-hidden={contentObscured}
|
2024-07-03 15:08:30 -04:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
const scrollingGrid = (
|
|
|
|
|
<Grid
|
|
|
|
|
key="scrolling"
|
|
|
|
|
className={styles.scrollingGrid}
|
|
|
|
|
model={layout}
|
|
|
|
|
Layout={layers.scrolling}
|
|
|
|
|
Tile={Tile}
|
2025-09-18 12:58:47 +02:00
|
|
|
aria-hidden={contentObscured}
|
2024-07-03 15:08:30 -04:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
// 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
|
|
|
<>
|
2024-07-03 15:08:30 -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(
|
2025-08-15 18:32:37 +02:00
|
|
|
matrixRoom.roomId,
|
2024-05-16 15:23:10 -04:00
|
|
|
);
|
2023-11-30 22:59:19 -05:00
|
|
|
|
2025-09-16 11:31:47 +02:00
|
|
|
const allLivekitRooms = useBehavior(vm.allLivekitRooms$);
|
|
|
|
|
const memberships = useBehavior(vm.memberships$);
|
2024-05-16 15:23:10 -04:00
|
|
|
|
2024-11-08 12:16:59 -05:00
|
|
|
const buttons: JSX.Element[] = [];
|
|
|
|
|
|
|
|
|
|
buttons.push(
|
|
|
|
|
<MicButton
|
|
|
|
|
key="audio"
|
2025-08-29 18:46:24 +02:00
|
|
|
muted={!audioEnabled}
|
|
|
|
|
onClick={toggleAudio ?? undefined}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2025-08-29 18:46:24 +02:00
|
|
|
disabled={toggleAudio === null}
|
2024-11-08 12:16:59 -05:00
|
|
|
data-testid="incall_mute"
|
|
|
|
|
/>,
|
|
|
|
|
<VideoButton
|
|
|
|
|
key="video"
|
2025-08-29 18:46:24 +02:00
|
|
|
muted={!videoEnabled}
|
|
|
|
|
onClick={toggleVideo ?? undefined}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2025-08-29 18:46:24 +02:00
|
|
|
disabled={toggleVideo === null}
|
2024-11-08 12:16:59 -05:00
|
|
|
data-testid="incall_videomute"
|
|
|
|
|
/>,
|
|
|
|
|
);
|
2025-09-24 13:54:54 -04:00
|
|
|
if (vm.toggleScreenSharing !== null) {
|
2024-05-16 15:23:10 -04:00
|
|
|
buttons.push(
|
2024-11-08 12:16:59 -05:00
|
|
|
<ShareScreenButton
|
|
|
|
|
key="share_screen"
|
|
|
|
|
className={styles.shareScreen}
|
2025-09-24 13:54:54 -04:00
|
|
|
enabled={sharingScreen}
|
|
|
|
|
onClick={vm.toggleScreenSharing}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2024-11-08 12:16:59 -05:00
|
|
|
data-testid="incall_screenshare"
|
2024-05-16 15:23:10 -04:00
|
|
|
/>,
|
|
|
|
|
);
|
2024-11-08 12:16:59 -05:00
|
|
|
}
|
|
|
|
|
if (supportsReactions) {
|
|
|
|
|
buttons.push(
|
2024-11-08 12:45:09 -05:00
|
|
|
<ReactionToggleButton
|
2024-12-19 15:54:28 +00:00
|
|
|
vm={vm}
|
2024-11-08 12:16:59 -05:00
|
|
|
key="raise_hand"
|
|
|
|
|
className={styles.raiseHand}
|
2024-12-19 15:54:28 +00:00
|
|
|
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2024-11-08 12:16:59 -05:00
|
|
|
/>,
|
2023-11-30 22:59:19 -05:00
|
|
|
);
|
2024-05-16 15:23:10 -04:00
|
|
|
}
|
2024-11-08 12:16:59 -05:00
|
|
|
if (layout.type !== "pip")
|
2024-11-08 10:23:19 -05:00
|
|
|
buttons.push(
|
|
|
|
|
<SettingsButton
|
|
|
|
|
key="settings"
|
|
|
|
|
onClick={openSettings}
|
|
|
|
|
onTouchEnd={onControlsTouchEnd}
|
|
|
|
|
/>,
|
|
|
|
|
);
|
2024-11-08 12:16:59 -05:00
|
|
|
|
|
|
|
|
buttons.push(
|
|
|
|
|
<EndCallButton
|
|
|
|
|
key="end_call"
|
|
|
|
|
onClick={function (): void {
|
2025-09-24 21:26:16 -04:00
|
|
|
vm.hangup();
|
2024-11-08 12:16:59 -05:00
|
|
|
}}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2024-11-08 12:16:59 -05:00
|
|
|
data-testid="incall_leave"
|
|
|
|
|
/>,
|
|
|
|
|
);
|
|
|
|
|
const footer = (
|
|
|
|
|
<div
|
|
|
|
|
ref={footerRef}
|
|
|
|
|
className={classNames(styles.footer, {
|
|
|
|
|
[styles.overlay]: windowMode === "flat",
|
2025-06-26 05:08:57 -04:00
|
|
|
[styles.hidden]:
|
|
|
|
|
!showFooter || (!showControls && headerStyle === "none"),
|
2024-11-08 12:16:59 -05:00
|
|
|
})}
|
|
|
|
|
>
|
2025-06-26 05:08:57 -04:00
|
|
|
{headerStyle !== "none" && (
|
2024-11-08 12:16:59 -05:00
|
|
|
<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"}
|
|
|
|
|
/>
|
2024-12-11 05:23:42 -05:00
|
|
|
{/* Don't mind this odd placement, it's just a little debug label */}
|
|
|
|
|
{debugTileLayout
|
|
|
|
|
? `Tiles generation: ${tileStoreGeneration}`
|
|
|
|
|
: undefined}
|
2024-11-08 12:16:59 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{showControls && <div className={styles.buttons}>{buttons}</div>}
|
|
|
|
|
{showControls && (
|
|
|
|
|
<LayoutToggle
|
|
|
|
|
className={styles.layout}
|
|
|
|
|
layout={gridMode}
|
|
|
|
|
setLayout={setGridMode}
|
2024-11-08 10:23:19 -05:00
|
|
|
onTouchEnd={onControlsTouchEnd}
|
2024-11-08 12:16:59 -05: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}
|
|
|
|
|
>
|
2025-06-26 05:08:57 -04:00
|
|
|
{header}
|
2025-04-11 10:07:50 +02:00
|
|
|
{
|
2025-04-11 17:05:57 +02:00
|
|
|
// TODO: remove this once we remove the developer flag gets removed and we have shipped to
|
|
|
|
|
// device transport as the default.
|
|
|
|
|
showToDeviceEncryption && (
|
2025-04-11 10:07:50 +02:00
|
|
|
<Text
|
|
|
|
|
style={{ height: 0, zIndex: 1, alignSelf: "center", margin: 0 }}
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
using to Device key transport
|
|
|
|
|
</Text>
|
|
|
|
|
)
|
|
|
|
|
}
|
2025-09-16 11:31:47 +02:00
|
|
|
{allLivekitRooms.map((roomItem) => (
|
|
|
|
|
<LivekitRoomAudioRenderer
|
|
|
|
|
key={roomItem.url}
|
|
|
|
|
livekitRoom={roomItem.room}
|
|
|
|
|
members={memberships}
|
|
|
|
|
muted={muteAllAudio}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
2024-05-16 15:23:10 -04:00
|
|
|
{renderContent()}
|
2025-05-13 21:11:12 +02:00
|
|
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
|
|
|
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
2025-09-18 12:58:47 +02:00
|
|
|
{reconnectingToast}
|
|
|
|
|
{earpieceOverlay}
|
2024-12-19 15:54:28 +00:00
|
|
|
<ReactionsOverlay vm={vm} />
|
2025-09-15 09:58:16 +02:00
|
|
|
{waitingOverlay}
|
2024-05-16 15:23:10 -04:00
|
|
|
{footer}
|
2024-11-08 12:16:59 -05:00
|
|
|
{layout.type !== "pip" && (
|
|
|
|
|
<>
|
|
|
|
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
|
|
|
|
<SettingsModal
|
|
|
|
|
client={client}
|
2025-08-15 18:32:37 +02:00
|
|
|
roomId={matrixRoom.roomId}
|
2024-11-08 12:16:59 -05:00
|
|
|
open={settingsModalOpen}
|
|
|
|
|
onDismiss={closeSettings}
|
|
|
|
|
tab={settingsTab}
|
|
|
|
|
onTabChange={setSettingsTab}
|
2025-09-22 14:18:23 +02:00
|
|
|
livekitRooms={allLivekitRooms}
|
2024-11-08 12:16:59 -05:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2024-05-16 15:23:10 -04:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|