Start refactoring some business logic into view models
As Element Call grows in complexity, it has become a pain point that our business logic remains so tightly coupled to the UI code. In particular, this has made testing difficult, and the complex semantics of React hooks are not a great match for arbitrary business logic. Here, I show the beginnings of what it would look like for us to adopt the MVVM pattern. I've created a CallViewModel and TileViewModel that expose their state to the UI as rxjs Observables, as well as a couple of helper functions for consuming view models in React code. This should contain no user-visible changes, but we need to watch out for regressions particularly around focus switching and promotion of speakers, because this was the logic I chose to refactor first.
This commit is contained in:
@@ -19,14 +19,11 @@ import {
|
||||
RoomAudioRenderer,
|
||||
RoomContext,
|
||||
useLocalParticipant,
|
||||
useParticipants,
|
||||
useTracks,
|
||||
} from "@livekit/components-react";
|
||||
import { usePreventScroll } from "@react-aria/overlays";
|
||||
import { ConnectionState, Room, Track } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room";
|
||||
import {
|
||||
FC,
|
||||
ReactNode,
|
||||
@@ -39,9 +36,9 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import classNames from "classnames";
|
||||
import { useStateObservable } from "@react-rxjs/core";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -54,19 +51,14 @@ import {
|
||||
SettingsButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import {
|
||||
useVideoGridLayout,
|
||||
TileDescriptor,
|
||||
VideoGrid,
|
||||
} from "../video-grid/VideoGrid";
|
||||
import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid";
|
||||
import { useShowConnectionStats } from "../settings/useSetting";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { ItemData, TileContent, VideoTile } from "../video-grid/VideoTile";
|
||||
import { VideoTile } from "../video-grid/VideoTile";
|
||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal } from "../settings/SettingsModal";
|
||||
@@ -81,18 +73,14 @@ import { MuteStates } from "./MuteStates";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
ECConnectionState,
|
||||
} from "../livekit/useECConnectionState";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import { useCallViewModel } from "../state/CallViewModel";
|
||||
import { subscribe } from "../state/subscribe";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
e2eeConfig: E2EEConfig;
|
||||
@@ -137,470 +125,333 @@ export interface InCallViewProps {
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
useWakeLock();
|
||||
export const InCallView: FC<InCallViewProps> = subscribe(
|
||||
({
|
||||
client,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
otelGroupCallMembership,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
usePreventScroll();
|
||||
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]);
|
||||
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]);
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
const boundsValid = bounds.height > 0;
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
const boundsValid = bounds.height > 0;
|
||||
// Merge the refs so they can attach to the same element
|
||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||
|
||||
const screenSharingTracks = useTracks(
|
||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||
{
|
||||
room: livekitRoom,
|
||||
},
|
||||
);
|
||||
const { layout, setLayout } = useVideoGridLayout(
|
||||
screenSharingTracks.length > 0,
|
||||
);
|
||||
|
||||
const [showConnectionStats] = useShowConnectionStats();
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
layout === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
const screenSharingTracks = useTracks(
|
||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||
{
|
||||
room: livekitRoom,
|
||||
},
|
||||
);
|
||||
const { layout, setLayout } = useVideoGridLayout(
|
||||
screenSharingTracks.length > 0,
|
||||
);
|
||||
}, [layout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLayout("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLayout("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const [showConnectionStats] = useShowConnectionStats();
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
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),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
widget?.api.transport.send(
|
||||
layout === "grid"
|
||||
? ElementWidgetActions.TileLayout
|
||||
: ElementWidgetActions.SpotlightLayout,
|
||||
{},
|
||||
);
|
||||
}, [layout]);
|
||||
|
||||
return () => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget!.lazyActions.off(
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
setLayout("grid");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
const onSpotlightLayout = (
|
||||
ev: CustomEvent<IWidgetApiRequest>,
|
||||
): void => {
|
||||
setLayout("spotlight");
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||
widget.lazyActions.on(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setLayout]);
|
||||
|
||||
const mobile = boundsValid && bounds.width <= 660;
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
return () => {
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.TileLayout,
|
||||
onTileLayout,
|
||||
);
|
||||
widget!.lazyActions.off(
|
||||
ElementWidgetActions.SpotlightLayout,
|
||||
onSpotlightLayout,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [setLayout]);
|
||||
|
||||
const items = useParticipantTiles(livekitRoom, rtcSession.room, connState);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(items);
|
||||
const mobile = boundsValid && bounds.width <= 660;
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
|
||||
// The maximised participant: either the participant that the user has
|
||||
// manually put in fullscreen, or the focused (active) participant if the
|
||||
// window is too small to show everyone
|
||||
const maximisedParticipant = useMemo(
|
||||
() =>
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
: null),
|
||||
[fullscreenItem, noControls, items],
|
||||
);
|
||||
const vm = useCallViewModel(rtcSession.room, livekitRoom, connState);
|
||||
const items = useStateObservable(vm.tiles);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(items);
|
||||
|
||||
const Grid =
|
||||
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
|
||||
// The maximised participant: either the participant that the user has
|
||||
// manually put in fullscreen, or the focused (active) participant if the
|
||||
// window is too small to show everyone
|
||||
const maximisedParticipant = useMemo(
|
||||
() =>
|
||||
fullscreenItem ??
|
||||
(noControls
|
||||
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||
: null),
|
||||
[fullscreenItem, noControls, items],
|
||||
);
|
||||
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const Grid =
|
||||
items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid;
|
||||
|
||||
// This state is lifted out of NewVideoGrid so that layout states can be
|
||||
// restored after a layout switch or upon exiting fullscreen
|
||||
const layoutStates = useLayoutStates();
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// This state is lifted out of NewVideoGrid so that layout states can be
|
||||
// restored after a layout switch or upon exiting fullscreen
|
||||
const layoutStates = useLayoutStates();
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>{t("waiting_for_participants")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTile
|
||||
vm={maximisedParticipant.data}
|
||||
maximised={true}
|
||||
fullscreen={maximisedParticipant === fullscreenItem}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
showSpeakingIndicator={false}
|
||||
showConnectionStats={showConnectionStats}
|
||||
matrixInfo={matrixInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={styles.centerMessage}>
|
||||
<p>{t("waiting_for_participants")}</p>
|
||||
</div>
|
||||
<Grid
|
||||
items={items}
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
layoutStates={layoutStates}
|
||||
>
|
||||
{({ data: vm, ...props }): ReactNode => (
|
||||
<VideoTile
|
||||
vm={vm}
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
showConnectionStats={showConnectionStats}
|
||||
matrixInfo={matrixInfo}
|
||||
{...props}
|
||||
ref={props.ref as Ref<HTMLDivElement>}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTile
|
||||
maximised={true}
|
||||
fullscreen={maximisedParticipant === fullscreenItem}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
data={maximisedParticipant.data}
|
||||
showSpeakingIndicator={false}
|
||||
showConnectionStats={showConnectionStats}
|
||||
matrixInfo={matrixInfo}
|
||||
/>
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
exitFullscreen();
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||
audio: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
});
|
||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
if (noControls) {
|
||||
footer = null;
|
||||
} else {
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="1"
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={toggleMicrophone}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={toggleCamera}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreenShareEnabled}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<HangupButton
|
||||
key="6"
|
||||
onPress={function (): void {
|
||||
onLeave();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
footer = (
|
||||
<div
|
||||
className={classNames(
|
||||
showControls
|
||||
? styles.footer
|
||||
: hideHeader
|
||||
? [styles.footer, styles.footerHidden]
|
||||
: [styles.footer, styles.footerThin],
|
||||
)}
|
||||
>
|
||||
{!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>}
|
||||
{!mobile && !hideHeader && showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
items={items}
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
layoutStates={layoutStates}
|
||||
>
|
||||
{(props): ReactNode => (
|
||||
<VideoTile
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
showConnectionStats={showConnectionStats}
|
||||
matrixInfo={matrixInfo}
|
||||
{...props}
|
||||
ref={props.ref as Ref<HTMLDivElement>}
|
||||
/>
|
||||
<div className={styles.inRoom} ref={containerRef}>
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{!reducedControls && showControls && onShareClick !== null && (
|
||||
<InviteButton onClick={onShareClick} />
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen],
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(async () => {
|
||||
exitFullscreen();
|
||||
await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, {
|
||||
audio: true,
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
});
|
||||
}, [localParticipant, isScreenShareEnabled, exitFullscreen]);
|
||||
|
||||
let footer: JSX.Element | null;
|
||||
|
||||
if (noControls) {
|
||||
footer = null;
|
||||
} else {
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
buttons.push(
|
||||
<MicButton
|
||||
key="1"
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={toggleMicrophone}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={toggleCamera}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
|
||||
if (!reducedControls) {
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ScreenshareButton
|
||||
key="3"
|
||||
enabled={isScreenShareEnabled}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
buttons.push(<SettingsButton key="4" onPress={openSettings} />);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<HangupButton
|
||||
key="6"
|
||||
onPress={function (): void {
|
||||
onLeave();
|
||||
}}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
footer = (
|
||||
<div
|
||||
className={classNames(
|
||||
showControls
|
||||
? styles.footer
|
||||
: hideHeader
|
||||
? [styles.footer, styles.footerHidden]
|
||||
: [styles.footer, styles.footerThin],
|
||||
)}
|
||||
>
|
||||
{!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>}
|
||||
{!mobile && !hideHeader && showControls && (
|
||||
<LayoutToggle
|
||||
className={styles.layout}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
/>
|
||||
<div className={styles.controlsOverlay}>
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
</div>
|
||||
{!noControls && (
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
)}
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.inRoom} ref={containerRef}>
|
||||
{!hideHeader && maximisedParticipant === null && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{!reducedControls && showControls && onShareClick !== null && (
|
||||
<InviteButton onClick={onShareClick} />
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.controlsOverlay}>
|
||||
<RoomAudioRenderer />
|
||||
{renderContent()}
|
||||
{footer}
|
||||
</div>
|
||||
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function findMatrixMember(
|
||||
room: MatrixRoom,
|
||||
id: string,
|
||||
): RoomMember | undefined {
|
||||
if (!id) return undefined;
|
||||
|
||||
const parts = id.split(":");
|
||||
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
|
||||
if (parts.length < 3) {
|
||||
logger.warn(
|
||||
"Livekit participants ID doesn't look like a userId:deviceId combination",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
parts.pop();
|
||||
const userId = parts.join(":");
|
||||
|
||||
return room.getMember(userId) ?? undefined;
|
||||
}
|
||||
|
||||
function useParticipantTiles(
|
||||
livekitRoom: Room,
|
||||
matrixRoom: MatrixRoom,
|
||||
connState: ECConnectionState,
|
||||
): TileDescriptor<ItemData>[] {
|
||||
const previousTiles = useRef<TileDescriptor<ItemData>[]>([]);
|
||||
|
||||
const sfuParticipants = useParticipants({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
let allGhosts = true;
|
||||
|
||||
const speakActiveTime = new Date();
|
||||
speakActiveTime.setSeconds(speakActiveTime.getSeconds() - 10);
|
||||
// Iterate over SFU participants (those who actually are present from the SFU perspective) and create tiles for them.
|
||||
const tiles: TileDescriptor<ItemData>[] = sfuParticipants.flatMap(
|
||||
(sfuParticipant) => {
|
||||
const spokeRecently =
|
||||
sfuParticipant.lastSpokeAt !== undefined &&
|
||||
sfuParticipant.lastSpokeAt > speakActiveTime;
|
||||
|
||||
const id = sfuParticipant.identity;
|
||||
const member = findMatrixMember(matrixRoom, id);
|
||||
// We always start with a local participant wit the empty string as their ID before we're
|
||||
// connected, this is fine and we'll be in "all ghosts" mode.
|
||||
if (id !== "" && member === undefined) {
|
||||
logger.warn(
|
||||
`Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`,
|
||||
);
|
||||
}
|
||||
allGhosts &&= member === undefined;
|
||||
|
||||
const userMediaTile = {
|
||||
id,
|
||||
focused: false,
|
||||
isPresenter: sfuParticipant.isScreenShareEnabled,
|
||||
isSpeaker:
|
||||
(sfuParticipant.isSpeaking || spokeRecently) &&
|
||||
!sfuParticipant.isLocal,
|
||||
hasVideo: sfuParticipant.isCameraEnabled,
|
||||
local: sfuParticipant.isLocal,
|
||||
largeBaseSize: false,
|
||||
data: {
|
||||
id,
|
||||
member,
|
||||
sfuParticipant,
|
||||
content: TileContent.UserMedia,
|
||||
},
|
||||
};
|
||||
|
||||
// If there is a screen sharing enabled for this participant, create a tile for it as well.
|
||||
let screenShareTile: TileDescriptor<ItemData> | undefined;
|
||||
if (sfuParticipant.isScreenShareEnabled) {
|
||||
const screenShareId = `${id}:screen-share`;
|
||||
screenShareTile = {
|
||||
...userMediaTile,
|
||||
id: screenShareId,
|
||||
focused: true,
|
||||
largeBaseSize: true,
|
||||
placeNear: id,
|
||||
data: {
|
||||
...userMediaTile.data,
|
||||
id: screenShareId,
|
||||
content: TileContent.ScreenShare,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return screenShareTile
|
||||
? [userMediaTile, screenShareTile]
|
||||
: [userMediaTile];
|
||||
},
|
||||
);
|
||||
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged(
|
||||
tiles.length,
|
||||
);
|
||||
|
||||
// If every item is a ghost, that probably means we're still connecting and
|
||||
// shouldn't bother showing anything yet
|
||||
return allGhosts ? [] : tiles;
|
||||
}, [matrixRoom, sfuParticipants]);
|
||||
|
||||
// We carry over old tiles from the previous focus for some time after a focus switch
|
||||
// so that the video tiles don't all disappear and reappear.
|
||||
// This is set to true when the state transitions to Switching Focus and remains
|
||||
// true for a short time after it changes (ie. connState is only switching focus for
|
||||
// the time it takes us to reconnect to the conference).
|
||||
// If there are still members that haven't reconnected after that time, they'll just
|
||||
// appear to disconnect and will reappear once they reconnect.
|
||||
const [isSwitchingFocus, setIsSwitchingFocus] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === ECAddonConnectionState.ECSwitchingFocus) {
|
||||
setIsSwitchingFocus(true);
|
||||
} else if (isSwitchingFocus) {
|
||||
setTimeout(() => {
|
||||
setIsSwitchingFocus(false);
|
||||
}, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS);
|
||||
}
|
||||
}, [connState, setIsSwitchingFocus, isSwitchingFocus]);
|
||||
|
||||
if (
|
||||
connState === ECAddonConnectionState.ECSwitchingFocus ||
|
||||
isSwitchingFocus
|
||||
) {
|
||||
logger.debug("Switching focus: injecting previous tiles");
|
||||
|
||||
// inject the previous tile for members that haven't rejoined yet
|
||||
const newItems = items.slice(0);
|
||||
const rejoined = new Set(newItems.map((p) => p.id));
|
||||
|
||||
for (const prevTile of previousTiles.current) {
|
||||
if (!rejoined.has(prevTile.id)) {
|
||||
newItems.push(prevTile);
|
||||
}
|
||||
}
|
||||
|
||||
return newItems;
|
||||
} else {
|
||||
previousTiles.current = items;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user