();
+
+ /**
+ * Instructs the ViewModel to clean up its resources. If you forget to call
+ * this, there may be memory leaks!
+ */
+ public destroy(): void {
+ this.destroyed.next();
+ this.destroyed.complete();
+ }
+}
diff --git a/src/state/subscribe.tsx b/src/state/subscribe.tsx
new file mode 100644
index 000000000..461a178c2
--- /dev/null
+++ b/src/state/subscribe.tsx
@@ -0,0 +1,38 @@
+/*
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { FC } from "react";
+// eslint-disable-next-line no-restricted-imports
+import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
+
+/**
+ * Wraps a React component that consumes Observables, resulting in a component
+ * that safely subscribes to its Observables before rendering. The component
+ * will return null until the subscriptions are created.
+ */
+export function subscribe(render: FC
): FC
{
+ const InnerComponent: FC<{ p: P }> = ({ p }) => (
+ {render(p)}
+ );
+ const OuterComponent: FC
= (p) => (
+
+
+
+ );
+ // Copy over the component's display name, default props, etc.
+ Object.assign(OuterComponent, render);
+ return OuterComponent;
+}
diff --git a/src/state/useObservable.ts b/src/state/useObservable.ts
new file mode 100644
index 000000000..a55a6e408
--- /dev/null
+++ b/src/state/useObservable.ts
@@ -0,0 +1,31 @@
+/*
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { useEffect, useRef } from "react";
+import { BehaviorSubject, Observable } from "rxjs";
+
+/**
+ * React hook that creates an Observable from a changing value. The Observable
+ * replays its current value upon subscription, emits whenever the value
+ * changes, and completes when the component is unmounted.
+ */
+export function useObservable(value: T): Observable {
+ const subject = useRef>();
+ subject.current ??= new BehaviorSubject(value);
+ if (value !== subject.current.value) subject.current.next(value);
+ useEffect(() => subject.current!.complete(), []);
+ return subject.current;
+}
diff --git a/src/usePrevious.ts b/src/usePrevious.ts
new file mode 100644
index 000000000..b298c10ea
--- /dev/null
+++ b/src/usePrevious.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2023 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { useRef } from "react";
+
+/**
+ * React hook that returns the value given on the previous render.
+ */
+export function usePrevious(value: T): T | undefined {
+ const ref = useRef();
+ const previous = ref.current;
+ ref.current = value;
+ return previous;
+}
diff --git a/src/video-grid/BigGrid.tsx b/src/video-grid/BigGrid.tsx
index 39b6d82d7..275523fef 100644
--- a/src/video-grid/BigGrid.tsx
+++ b/src/video-grid/BigGrid.tsx
@@ -19,11 +19,11 @@ import { RectReadOnly } from "react-use-measure";
import { FC, memo, ReactNode } from "react";
import { zip } from "lodash";
-import { TileDescriptor } from "./VideoGrid";
import { Slot } from "./NewVideoGrid";
import { Layout } from "./Layout";
import { count, findLastIndex } from "../array-utils";
import styles from "./BigGrid.module.css";
+import { TileDescriptor } from "../state/CallViewModel";
/**
* A 1×1 cell in a grid which belongs to a tile.
diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx
index fe8f2ec70..b540cbe17 100644
--- a/src/video-grid/Layout.tsx
+++ b/src/video-grid/Layout.tsx
@@ -18,7 +18,7 @@ import { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react";
import type { RectReadOnly } from "react-use-measure";
import { useReactiveState } from "../useReactiveState";
-import type { TileDescriptor } from "./VideoGrid";
+import { TileDescriptor } from "../state/CallViewModel";
/**
* A video grid layout system with concrete states of type State.
diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx
index 8b32045c5..6de5f58dd 100644
--- a/src/video-grid/NewVideoGrid.tsx
+++ b/src/video-grid/NewVideoGrid.tsx
@@ -32,7 +32,6 @@ import styles from "./NewVideoGrid.module.css";
import {
VideoGridProps as Props,
TileSpring,
- TileDescriptor,
ChildrenProperties,
} from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
@@ -40,6 +39,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { BigGrid } from "./BigGrid";
import { useLayout } from "./Layout";
+import { TileDescriptor } from "../state/CallViewModel";
interface Rect {
x: number;
diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx
index d918cd26e..73e884e41 100644
--- a/src/video-grid/VideoGrid.tsx
+++ b/src/video-grid/VideoGrid.tsx
@@ -44,6 +44,7 @@ import styles from "./VideoGrid.module.css";
import { Layout } from "../room/LayoutToggle";
import { TileWrapper } from "./TileWrapper";
import { LayoutStatesMap } from "./Layout";
+import { TileDescriptor } from "../state/CallViewModel";
interface TilePosition {
x: number;
@@ -838,20 +839,6 @@ export interface VideoGridProps {
children: (props: ChildrenProperties) => ReactNode;
}
-// Represents something that should get a tile on the layout,
-// ie. a user's video feed or a screen share feed.
-export interface TileDescriptor {
- id: string;
- focused: boolean;
- isPresenter: boolean;
- isSpeaker: boolean;
- hasVideo: boolean;
- local: boolean;
- largeBaseSize: boolean;
- placeNear?: string;
- data: T;
-}
-
export function VideoGrid({
items,
layout,
diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx
index 7a785a353..1bb67b226 100644
--- a/src/video-grid/VideoTile.tsx
+++ b/src/video-grid/VideoTile.tsx
@@ -45,6 +45,11 @@ import { useReactiveState } from "../useReactiveState";
import { AudioButton, FullscreenButton } from "../button/Button";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { MatrixInfo } from "../room/VideoPreview";
+import {
+ ScreenShareTileViewModel,
+ TileViewModel,
+ UserMediaTileViewModel,
+} from "../state/TileViewModel";
export interface ItemData {
id: string;
@@ -59,7 +64,7 @@ export enum TileContent {
}
interface Props {
- data: ItemData;
+ vm: TileViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
@@ -78,7 +83,7 @@ interface Props {
export const VideoTile = forwardRef(
(
{
- data,
+ vm,
maximised,
fullscreen,
onToggleFullscreen,
@@ -94,7 +99,7 @@ export const VideoTile = forwardRef(
) => {
const { t } = useTranslation();
- const { content, sfuParticipant, member } = data;
+ const { id, sfuParticipant, member } = vm;
// Handle display name changes.
const [displayName, setDisplayName] = useReactiveState(
@@ -115,13 +120,13 @@ export const VideoTile = forwardRef(
}, [member, setDisplayName]);
const audioInfo = useMediaTrack(
- content === TileContent.UserMedia
+ vm instanceof UserMediaTileViewModel
? Track.Source.Microphone
: Track.Source.ScreenShareAudio,
sfuParticipant,
);
const videoInfo = useMediaTrack(
- content === TileContent.UserMedia
+ vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare,
sfuParticipant,
@@ -134,8 +139,8 @@ export const VideoTile = forwardRef(
const MicIcon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const onFullscreen = useCallback(() => {
- onToggleFullscreen(data.id);
- }, [data, onToggleFullscreen]);
+ onToggleFullscreen(id);
+ }, [id, onToggleFullscreen]);
const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] =
useState(false);
@@ -159,7 +164,7 @@ export const VideoTile = forwardRef(
/>,
);
- if (content === TileContent.ScreenShare) {
+ if (vm instanceof ScreenShareTileViewModel) {
toolbarButtons.push(
(
[styles.isLocal]: sfuParticipant.isLocal,
[styles.speaking]:
sfuParticipant.isSpeaking &&
- content === TileContent.UserMedia &&
+ vm instanceof UserMediaTileViewModel &&
showSpeakingIndicator,
- [styles.screenshare]: content === TileContent.ScreenShare,
+ [styles.screenshare]: vm instanceof ScreenShareTileViewModel,
[styles.maximised]: maximised,
})}
style={style}
@@ -189,7 +194,7 @@ export const VideoTile = forwardRef(
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
{toolbarButtons}
)}
- {content === TileContent.UserMedia &&
+ {vm instanceof UserMediaTileViewModel &&
!sfuParticipant.isCameraEnabled && (
<>
@@ -203,7 +208,7 @@ export const VideoTile = forwardRef(
/>
>
)}
- {content === TileContent.ScreenShare ? (
+ {vm instanceof ScreenShareTileViewModel ? (
{t("video_tile.presenter_label", { displayName })}
@@ -245,7 +250,7 @@ export const VideoTile = forwardRef(
(
// eslint-disable-next-line react/no-unknown-property
disablepictureinpicture="true"
/>
- {!maximised && (
+ {!maximised && sfuParticipant instanceof RemoteParticipant && (
diff --git a/src/video-grid/VideoTileSettingsModal.tsx b/src/video-grid/VideoTileSettingsModal.tsx
index b8e464c94..ad9bcad71 100644
--- a/src/video-grid/VideoTileSettingsModal.tsx
+++ b/src/video-grid/VideoTileSettingsModal.tsx
@@ -22,19 +22,18 @@ import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
import styles from "./VideoTileSettingsModal.module.css";
import { VolumeIcon } from "../button/VolumeIcon";
-import { ItemData, TileContent } from "./VideoTile";
interface LocalVolumeProps {
participant: RemoteParticipant;
- content: TileContent;
+ media: "user media" | "screen share";
}
const LocalVolume: FC = ({
participant,
- content,
+ media,
}: LocalVolumeProps) => {
const source =
- content === TileContent.UserMedia
+ media === "user media"
? Track.Source.Microphone
: Track.Source.ScreenShareAudio;
@@ -67,13 +66,15 @@ const LocalVolume: FC = ({
};
interface Props {
- data: ItemData;
+ participant: RemoteParticipant;
+ media: "user media" | "screen share";
open: boolean;
onDismiss: () => void;
}
export const VideoTileSettingsModal: FC = ({
- data,
+ participant,
+ media,
open,
onDismiss,
}) => {
@@ -87,10 +88,7 @@ export const VideoTileSettingsModal: FC = ({
onDismiss={onDismiss}
>
-
+
);
diff --git a/test/video-grid/BigGrid-test.ts b/test/video-grid/BigGrid-test.ts
index 06e73dac7..3d29db6cc 100644
--- a/test/video-grid/BigGrid-test.ts
+++ b/test/video-grid/BigGrid-test.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import { TileDescriptor } from "../../src/state/CallViewModel";
import {
addItems,
column,
@@ -26,7 +27,6 @@ import {
row,
moveTile,
} from "../../src/video-grid/BigGrid";
-import { TileDescriptor } from "../../src/video-grid/VideoGrid";
/**
* Builds a grid from a string specifying the contents of each cell as a letter.
@@ -315,7 +315,7 @@ jk`;
const grid = mkGrid(input);
let gridAfter = grid;
- const toggle = (tileId: string) => {
+ const toggle = (tileId: string): void => {
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
gridAfter = cycleTileSize(gridAfter, tile);
};
diff --git a/test/video-grid/VideoGrid-test.ts b/test/video-grid/VideoGrid-test.ts
index 46134a13e..cf15c0221 100644
--- a/test/video-grid/VideoGrid-test.ts
+++ b/test/video-grid/VideoGrid-test.ts
@@ -14,11 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import {
- Tile,
- TileDescriptor,
- reorderTiles,
-} from "../../src/video-grid/VideoGrid";
+import { TileDescriptor } from "../../src/state/CallViewModel";
+import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid";
const alice: Tile = {
key: "alice",
diff --git a/yarn.lock b/yarn.lock
index 2938fe267..2ec5393d7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2050,6 +2050,17 @@
loglevel "^1.8.1"
rxjs "^7.8.0"
+"@livekit/components-core@^0.7.0":
+ version "0.7.0"
+ resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.7.0.tgz#1283a34753c8f1bb805b987684a3e29d1bc2eb39"
+ integrity sha512-KwzqnW8V9G+4fXc4gOxpXqQFLpL/kFBn82sYO10zHjHfNKSw4pRj1sDLTWc5UF22W2Z461/bqMtB+3WGB/3Dtg==
+ dependencies:
+ "@floating-ui/dom" "^1.1.0"
+ email-regex "^5.0.0"
+ global-tld-list "^0.0.1139"
+ loglevel "^1.8.1"
+ rxjs "^7.8.0"
+
"@livekit/components-react@^1.1.0":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-1.4.2.tgz#f4b09621a244fa832803ca0fc4b200a548dc3b8d"
@@ -2726,6 +2737,14 @@
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==
+"@react-rxjs/core@^0.10.7":
+ version "0.10.7"
+ resolved "https://registry.yarnpkg.com/@react-rxjs/core/-/core-0.10.7.tgz#09951f43a6c80892526ac13d51859098b0e74993"
+ integrity sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A==
+ dependencies:
+ "@rx-state/core" "0.1.4"
+ use-sync-external-store "^1.0.0"
+
"@react-spring/animated@~9.7.3":
version "9.7.3"
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
@@ -3045,6 +3064,11 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz#45785b5caf83200a34a9867ba50d69560880c120"
integrity sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A==
+"@rx-state/core@0.1.4":
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/@rx-state/core/-/core-0.1.4.tgz#586dde80be9dbdac31844006a0dcaa2bc7f35a5c"
+ integrity sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ==
+
"@sentry-internal/tracing@7.83.0":
version "7.83.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.83.0.tgz#8f69d339569b020c495f8350a8ea527c369586e8"
@@ -5848,6 +5872,11 @@ glob@^7.0.0, glob@^7.1.3, glob@^7.1.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
+global-tld-list@^0.0.1139:
+ version "0.0.1139"
+ resolved "https://registry.yarnpkg.com/global-tld-list/-/global-tld-list-0.0.1139.tgz#70400a3f3ccac1a19a8184274a1b117bc8a27969"
+ integrity sha512-TCWjAwHPzFV6zbQ5jnJvJTctesHGJr9BppxivRuIxTiIFUzaxy1F0674cxjoJecW5s8V32Q5i35dBFqvAy7eGQ==
+
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -8579,7 +8608,7 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
-rxjs@^7.5.2, rxjs@^7.8.0:
+rxjs@^7.5.2, rxjs@^7.8.0, rxjs@^7.8.1:
version "7.8.1"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
@@ -9385,6 +9414,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
+use-sync-external-store@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
+ integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
+
usehooks-ts@^2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.9.1.tgz#953d3284851ffd097432379e271ce046a8180b37"