Merge pull request #2381 from robintown/observable-hooks
Replace react-rxjs with observable-hooks
This commit is contained in:
@@ -38,15 +38,6 @@ module.exports = {
|
|||||||
"jsx-a11y/media-has-caption": "off",
|
"jsx-a11y/media-has-caption": "off",
|
||||||
// We should use the js-sdk logger, never console directly.
|
// We should use the js-sdk logger, never console directly.
|
||||||
"no-console": ["error"],
|
"no-console": ["error"],
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
name: "@react-rxjs/core",
|
|
||||||
importNames: ["Subscribe", "RemoveSubscribe"],
|
|
||||||
message:
|
|
||||||
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"react/display-name": "error",
|
"react/display-name": "error",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"@react-aria/tabs": "^3.1.0",
|
"@react-aria/tabs": "^3.1.0",
|
||||||
"@react-aria/tooltip": "^3.1.3",
|
"@react-aria/tooltip": "^3.1.3",
|
||||||
"@react-aria/utils": "^3.10.0",
|
"@react-aria/utils": "^3.10.0",
|
||||||
"@react-rxjs/core": "^0.10.7",
|
|
||||||
"@react-spring/web": "^9.4.4",
|
"@react-spring/web": "^9.4.4",
|
||||||
"@react-stately/collections": "^3.3.4",
|
"@react-stately/collections": "^3.3.4",
|
||||||
"@react-stately/select": "^3.1.3",
|
"@react-stately/select": "^3.1.3",
|
||||||
|
|||||||
@@ -14,16 +14,15 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CSSProperties, useMemo } from "react";
|
import { CSSProperties, forwardRef, useMemo } from "react";
|
||||||
import { StateObservable, state, useStateObservable } from "@react-rxjs/core";
|
import { BehaviorSubject, Observable, distinctUntilChanged } from "rxjs";
|
||||||
import { BehaviorSubject, distinctUntilChanged } from "rxjs";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
|
||||||
import { MediaViewModel } from "../state/MediaViewModel";
|
import { MediaViewModel } from "../state/MediaViewModel";
|
||||||
import { LayoutSystem, Slot } from "./Grid";
|
import { LayoutSystem, Slot } from "./Grid";
|
||||||
import styles from "./GridLayout.module.css";
|
import styles from "./GridLayout.module.css";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { subscribe } from "../state/subscribe";
|
|
||||||
import { Alignment } from "../room/InCallView";
|
import { Alignment } from "../room/InCallView";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ const slotMaxAspectRatio = 17 / 9;
|
|||||||
const slotMinAspectRatio = 4 / 3;
|
const slotMinAspectRatio = 4 / 3;
|
||||||
|
|
||||||
export const gridLayoutSystems = (
|
export const gridLayoutSystems = (
|
||||||
minBounds: StateObservable<Bounds>,
|
minBounds: Observable<Bounds>,
|
||||||
floatingAlignment: BehaviorSubject<Alignment>,
|
floatingAlignment: BehaviorSubject<Alignment>,
|
||||||
): GridLayoutSystems => ({
|
): GridLayoutSystems => ({
|
||||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||||
@@ -58,15 +57,13 @@ export const gridLayoutSystems = (
|
|||||||
new Map(
|
new Map(
|
||||||
model.spotlight === undefined ? [] : [["spotlight", model.spotlight]],
|
model.spotlight === undefined ? [] : [["spotlight", model.spotlight]],
|
||||||
),
|
),
|
||||||
Layout: subscribe(function GridLayoutFixed({ model }, ref) {
|
Layout: forwardRef(function GridLayoutFixed({ model }, ref) {
|
||||||
const { width, height } = useStateObservable(minBounds);
|
const { width, height } = useObservableEagerState(minBounds);
|
||||||
const alignment = useStateObservable(
|
const alignment = useObservableEagerState(
|
||||||
useInitial<StateObservable<Alignment>>(() =>
|
useInitial(() =>
|
||||||
state(
|
floatingAlignment.pipe(
|
||||||
floatingAlignment.pipe(
|
distinctUntilChanged(
|
||||||
distinctUntilChanged(
|
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
||||||
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -106,8 +103,8 @@ export const gridLayoutSystems = (
|
|||||||
// The scrolling part of the layout is where all the grid tiles live
|
// The scrolling part of the layout is where all the grid tiles live
|
||||||
scrolling: {
|
scrolling: {
|
||||||
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])),
|
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])),
|
||||||
Layout: subscribe(function GridLayout({ model }, ref) {
|
Layout: forwardRef(function GridLayout({ model }, ref) {
|
||||||
const { width, height: minHeight } = useStateObservable(minBounds);
|
const { width, height: minHeight } = useObservableEagerState(minBounds);
|
||||||
|
|
||||||
// The goal here is to determine the grid size and padding that maximizes
|
// The goal here is to determine the grid size and padding that maximizes
|
||||||
// use of screen space for n tiles without making those tiles too small or
|
// use of screen space for n tiles without making those tiles too small or
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ import {
|
|||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { state, useStateObservable } from "@react-rxjs/core";
|
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
import LogoMark from "../icons/LogoMark.svg?react";
|
||||||
@@ -76,7 +76,6 @@ import {
|
|||||||
TileDescriptor,
|
TileDescriptor,
|
||||||
useCallViewModel,
|
useCallViewModel,
|
||||||
} from "../state/CallViewModel";
|
} from "../state/CallViewModel";
|
||||||
import { subscribe } from "../state/subscribe";
|
|
||||||
import { Grid, TileProps } from "../grid/Grid";
|
import { Grid, TileProps } from "../grid/Grid";
|
||||||
import { MediaViewModel } from "../state/MediaViewModel";
|
import { MediaViewModel } from "../state/MediaViewModel";
|
||||||
import { gridLayoutSystems } from "../grid/GridLayout";
|
import { gridLayoutSystems } from "../grid/GridLayout";
|
||||||
@@ -143,458 +142,449 @@ export interface InCallViewProps {
|
|||||||
onShareClick: (() => void) | null;
|
onShareClick: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InCallView: FC<InCallViewProps> = subscribe(
|
export const InCallView: FC<InCallViewProps> = ({
|
||||||
({
|
client,
|
||||||
client,
|
matrixInfo,
|
||||||
matrixInfo,
|
rtcSession,
|
||||||
rtcSession,
|
livekitRoom,
|
||||||
livekitRoom,
|
muteStates,
|
||||||
muteStates,
|
participantCount,
|
||||||
participantCount,
|
onLeave,
|
||||||
onLeave,
|
hideHeader,
|
||||||
hideHeader,
|
otelGroupCallMembership,
|
||||||
otelGroupCallMembership,
|
connState,
|
||||||
connState,
|
onShareClick,
|
||||||
onShareClick,
|
}) => {
|
||||||
}) => {
|
const { t } = useTranslation();
|
||||||
const { t } = useTranslation();
|
usePreventScroll();
|
||||||
usePreventScroll();
|
useWakeLock();
|
||||||
useWakeLock();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connState === ConnectionState.Disconnected) {
|
if (connState === ConnectionState.Disconnected) {
|
||||||
// annoyingly we don't get the disconnection reason this way,
|
// annoyingly we don't get the disconnection reason this way,
|
||||||
// only by listening for the emitted event
|
// only by listening for the emitted event
|
||||||
onLeave(new Error("Disconnected from call server"));
|
onLeave(new Error("Disconnected from call server"));
|
||||||
}
|
}
|
||||||
}, [connState, onLeave]);
|
}, [connState, onLeave]);
|
||||||
|
|
||||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerRef2, bounds] = useMeasure();
|
const [containerRef2, bounds] = useMeasure();
|
||||||
const boundsValid = bounds.height > 0;
|
const boundsValid = bounds.height > 0;
|
||||||
// Merge the refs so they can attach to the same element
|
// Merge the refs so they can attach to the same element
|
||||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||||
|
|
||||||
const screenSharingTracks = useTracks(
|
const screenSharingTracks = useTracks(
|
||||||
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
[{ source: Track.Source.ScreenShare, withPlaceholder: false }],
|
||||||
{
|
{
|
||||||
room: livekitRoom,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { layout: legacyLayout, setLayout: setLegacyLayout } =
|
|
||||||
useLegacyGridLayout(screenSharingTracks.length > 0);
|
|
||||||
|
|
||||||
const { hideScreensharing, showControls } = useUrlParams();
|
|
||||||
|
|
||||||
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
|
||||||
room: livekitRoom,
|
room: livekitRoom,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
const { layout: legacyLayout, setLayout: setLegacyLayout } =
|
||||||
|
useLegacyGridLayout(screenSharingTracks.length > 0);
|
||||||
|
|
||||||
const toggleMicrophone = useCallback(
|
const { hideScreensharing, showControls } = useUrlParams();
|
||||||
() => 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.
|
const { isScreenShareEnabled, localParticipant } = useLocalParticipant({
|
||||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
room: livekitRoom,
|
||||||
useCallViewKeyboardShortcuts(
|
});
|
||||||
containerRef1,
|
|
||||||
toggleMicrophone,
|
|
||||||
toggleCamera,
|
|
||||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleMicrophone = useCallback(
|
||||||
widget?.api.transport.send(
|
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||||
legacyLayout === "grid"
|
[muteStates],
|
||||||
? ElementWidgetActions.TileLayout
|
);
|
||||||
: ElementWidgetActions.SpotlightLayout,
|
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(
|
||||||
|
legacyLayout === "grid"
|
||||||
|
? ElementWidgetActions.TileLayout
|
||||||
|
: ElementWidgetActions.SpotlightLayout,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}, [legacyLayout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (widget) {
|
||||||
|
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
setLegacyLayout("grid");
|
||||||
|
widget!.api.transport.reply(ev.detail, {});
|
||||||
|
};
|
||||||
|
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
setLegacyLayout("spotlight");
|
||||||
|
widget!.api.transport.reply(ev.detail, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
||||||
|
widget.lazyActions.on(
|
||||||
|
ElementWidgetActions.SpotlightLayout,
|
||||||
|
onSpotlightLayout,
|
||||||
);
|
);
|
||||||
}, [legacyLayout]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (): void => {
|
||||||
if (widget) {
|
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
|
||||||
const onTileLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
widget!.lazyActions.off(
|
||||||
setLegacyLayout("grid");
|
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
|
||||||
};
|
|
||||||
const onSpotlightLayout = (
|
|
||||||
ev: CustomEvent<IWidgetApiRequest>,
|
|
||||||
): void => {
|
|
||||||
setLegacyLayout("spotlight");
|
|
||||||
widget!.api.transport.reply(ev.detail, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout);
|
|
||||||
widget.lazyActions.on(
|
|
||||||
ElementWidgetActions.SpotlightLayout,
|
ElementWidgetActions.SpotlightLayout,
|
||||||
onSpotlightLayout,
|
onSpotlightLayout,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [setLegacyLayout]);
|
||||||
|
|
||||||
return (): void => {
|
const mobile = boundsValid && bounds.width <= 660;
|
||||||
widget!.lazyActions.off(
|
const reducedControls = boundsValid && bounds.width <= 340;
|
||||||
ElementWidgetActions.TileLayout,
|
const noControls = reducedControls && bounds.height <= 400;
|
||||||
onTileLayout,
|
|
||||||
);
|
|
||||||
widget!.lazyActions.off(
|
|
||||||
ElementWidgetActions.SpotlightLayout,
|
|
||||||
onSpotlightLayout,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [setLegacyLayout]);
|
|
||||||
|
|
||||||
const mobile = boundsValid && bounds.width <= 660;
|
const vm = useCallViewModel(
|
||||||
const reducedControls = boundsValid && bounds.width <= 340;
|
rtcSession.room,
|
||||||
const noControls = reducedControls && bounds.height <= 400;
|
livekitRoom,
|
||||||
|
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
||||||
|
connState,
|
||||||
|
);
|
||||||
|
const items = useObservableEagerState(vm.tiles);
|
||||||
|
const layout = useObservableEagerState(vm.layout);
|
||||||
|
const hasSpotlight = layout.spotlight !== undefined;
|
||||||
|
// Hack: We insert a dummy "spotlight" tile into the tiles we pass to
|
||||||
|
// useFullscreen so that we can control the fullscreen state of the
|
||||||
|
// spotlight tile in the new layouts with this same hook.
|
||||||
|
const fullscreenItems = useMemo(
|
||||||
|
() => (hasSpotlight ? [...items, dummySpotlightItem] : items),
|
||||||
|
[items, hasSpotlight],
|
||||||
|
);
|
||||||
|
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||||
|
useFullscreen(fullscreenItems);
|
||||||
|
const toggleSpotlightFullscreen = useCallback(
|
||||||
|
() => toggleFullscreen("spotlight"),
|
||||||
|
[toggleFullscreen],
|
||||||
|
);
|
||||||
|
|
||||||
const vm = useCallViewModel(
|
// The maximised participant: either the participant that the user has
|
||||||
rtcSession.room,
|
// manually put in fullscreen, or the focused (active) participant if the
|
||||||
livekitRoom,
|
// window is too small to show everyone
|
||||||
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
|
const maximisedParticipant = useMemo(
|
||||||
connState,
|
() =>
|
||||||
);
|
fullscreenItem ??
|
||||||
const items = useStateObservable(vm.tiles);
|
(noControls
|
||||||
const layout = useStateObservable(vm.layout);
|
? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null
|
||||||
const hasSpotlight = layout.spotlight !== undefined;
|
: null),
|
||||||
// Hack: We insert a dummy "spotlight" tile into the tiles we pass to
|
[fullscreenItem, noControls, items],
|
||||||
// useFullscreen so that we can control the fullscreen state of the
|
);
|
||||||
// spotlight tile in the new layouts with this same hook.
|
|
||||||
const fullscreenItems = useMemo(
|
|
||||||
() => (hasSpotlight ? [...items, dummySpotlightItem] : items),
|
|
||||||
[items, hasSpotlight],
|
|
||||||
);
|
|
||||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
|
||||||
useFullscreen(fullscreenItems);
|
|
||||||
const toggleSpotlightFullscreen = useCallback(
|
|
||||||
() => toggleFullscreen("spotlight"),
|
|
||||||
[toggleFullscreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
// The maximised participant: either the participant that the user has
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
// 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 [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
|
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const openSettings = useCallback(
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
() => setSettingsModalOpen(true),
|
||||||
|
[setSettingsModalOpen],
|
||||||
|
);
|
||||||
|
const closeSettings = useCallback(
|
||||||
|
() => setSettingsModalOpen(false),
|
||||||
|
[setSettingsModalOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const openSettings = useCallback(
|
const openProfile = useCallback(() => {
|
||||||
() => setSettingsModalOpen(true),
|
setSettingsTab("profile");
|
||||||
[setSettingsModalOpen],
|
setSettingsModalOpen(true);
|
||||||
);
|
}, [setSettingsTab, setSettingsModalOpen]);
|
||||||
const closeSettings = useCallback(
|
|
||||||
() => setSettingsModalOpen(false),
|
|
||||||
[setSettingsModalOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const openProfile = useCallback(() => {
|
const [headerRef, headerBounds] = useMeasure();
|
||||||
setSettingsTab("profile");
|
const [footerRef, footerBounds] = useMeasure();
|
||||||
setSettingsModalOpen(true);
|
const gridBounds = useMemo(
|
||||||
}, [setSettingsTab, setSettingsModalOpen]);
|
() => ({
|
||||||
|
width: footerBounds.width,
|
||||||
|
height: bounds.height - headerBounds.height - footerBounds.height,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
footerBounds.width,
|
||||||
|
bounds.height,
|
||||||
|
headerBounds.height,
|
||||||
|
footerBounds.height,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const gridBoundsObservable = useObservable(gridBounds);
|
||||||
|
const floatingAlignment = useInitial(
|
||||||
|
() => new BehaviorSubject(defaultAlignment),
|
||||||
|
);
|
||||||
|
const { fixed, scrolling } = useInitial(() =>
|
||||||
|
gridLayoutSystems(gridBoundsObservable, floatingAlignment),
|
||||||
|
);
|
||||||
|
|
||||||
const [headerRef, headerBounds] = useMeasure();
|
const setGridMode = useCallback(
|
||||||
const [footerRef, footerBounds] = useMeasure();
|
(mode: GridMode) => {
|
||||||
const gridBounds = useMemo(
|
setLegacyLayout(mode);
|
||||||
() => ({
|
vm.setGridMode(mode);
|
||||||
width: footerBounds.width,
|
},
|
||||||
height: bounds.height - headerBounds.height - footerBounds.height,
|
[setLegacyLayout, vm],
|
||||||
}),
|
);
|
||||||
[
|
|
||||||
footerBounds.width,
|
|
||||||
bounds.height,
|
|
||||||
headerBounds.height,
|
|
||||||
footerBounds.height,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const gridBoundsObservable = useObservable(gridBounds);
|
|
||||||
const floatingAlignment = useInitial(
|
|
||||||
() => new BehaviorSubject(defaultAlignment),
|
|
||||||
);
|
|
||||||
const { fixed, scrolling } = useInitial(() =>
|
|
||||||
gridLayoutSystems(state(gridBoundsObservable), floatingAlignment),
|
|
||||||
);
|
|
||||||
|
|
||||||
const setGridMode = useCallback(
|
const showSpeakingIndicators =
|
||||||
(mode: GridMode) => {
|
layout.type === "spotlight" ||
|
||||||
setLegacyLayout(mode);
|
(layout.type === "grid" && layout.grid.length > 2);
|
||||||
vm.setGridMode(mode);
|
|
||||||
},
|
|
||||||
[setLegacyLayout, vm],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showSpeakingIndicators =
|
const SpotlightTileView = useMemo(
|
||||||
layout.type === "spotlight" ||
|
() =>
|
||||||
(layout.type === "grid" && layout.grid.length > 2);
|
forwardRef<HTMLDivElement, TileProps<MediaViewModel[], HTMLDivElement>>(
|
||||||
|
function SpotlightTileView(
|
||||||
const SpotlightTileView = useMemo(
|
{ className, style, targetWidth, targetHeight, model },
|
||||||
() =>
|
ref,
|
||||||
forwardRef<HTMLDivElement, TileProps<MediaViewModel[], HTMLDivElement>>(
|
) {
|
||||||
function SpotlightTileView(
|
|
||||||
{ className, style, targetWidth, targetHeight, model },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<SpotlightTile
|
|
||||||
ref={ref}
|
|
||||||
vms={model}
|
|
||||||
maximised={false}
|
|
||||||
fullscreen={false}
|
|
||||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[toggleSpotlightFullscreen],
|
|
||||||
);
|
|
||||||
const GridTileView = useMemo(
|
|
||||||
() =>
|
|
||||||
forwardRef<HTMLDivElement, TileProps<MediaViewModel, HTMLDivElement>>(
|
|
||||||
function GridTileView(
|
|
||||||
{ className, style, targetWidth, targetHeight, model },
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<GridTile
|
|
||||||
ref={ref}
|
|
||||||
vm={model}
|
|
||||||
maximised={false}
|
|
||||||
fullscreen={false}
|
|
||||||
onToggleFullscreen={toggleFullscreen}
|
|
||||||
onOpenProfile={openProfile}
|
|
||||||
targetWidth={targetWidth}
|
|
||||||
targetHeight={targetHeight}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
showSpeakingIndicators={showSpeakingIndicators}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
[toggleFullscreen, openProfile, showSpeakingIndicators],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={styles.centerMessage}>
|
|
||||||
<p>{t("waiting_for_participants")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maximisedParticipant !== null) {
|
|
||||||
const fullscreen = maximisedParticipant === fullscreenItem;
|
|
||||||
if (maximisedParticipant.id === "spotlight") {
|
|
||||||
return (
|
return (
|
||||||
<SpotlightTile
|
<SpotlightTile
|
||||||
vms={layout.spotlight!}
|
ref={ref}
|
||||||
maximised={true}
|
vms={model}
|
||||||
fullscreen={fullscreen}
|
maximised={false}
|
||||||
|
fullscreen={false}
|
||||||
onToggleFullscreen={toggleSpotlightFullscreen}
|
onToggleFullscreen={toggleSpotlightFullscreen}
|
||||||
targetWidth={gridBounds.height}
|
targetWidth={targetWidth}
|
||||||
targetHeight={gridBounds.width}
|
targetHeight={targetHeight}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return (
|
),
|
||||||
<GridTile
|
[toggleSpotlightFullscreen],
|
||||||
vm={maximisedParticipant.data}
|
);
|
||||||
maximised={true}
|
const GridTileView = useMemo(
|
||||||
fullscreen={fullscreen}
|
() =>
|
||||||
onToggleFullscreen={toggleFullscreen}
|
forwardRef<HTMLDivElement, TileProps<MediaViewModel, HTMLDivElement>>(
|
||||||
targetHeight={gridBounds.height}
|
function GridTileView(
|
||||||
targetWidth={gridBounds.width}
|
{ className, style, targetWidth, targetHeight, model },
|
||||||
key={maximisedParticipant.id}
|
ref,
|
||||||
showSpeakingIndicators={false}
|
) {
|
||||||
onOpenProfile={openProfile}
|
return (
|
||||||
/>
|
<GridTile
|
||||||
);
|
ref={ref}
|
||||||
}
|
vm={model}
|
||||||
|
maximised={false}
|
||||||
// The only new layout we've implemented so far is grid layout for non-1:1
|
fullscreen={false}
|
||||||
// calls. All other layouts use the legacy grid system for now.
|
onToggleFullscreen={toggleFullscreen}
|
||||||
if (
|
onOpenProfile={openProfile}
|
||||||
legacyLayout === "grid" &&
|
targetWidth={targetWidth}
|
||||||
layout.type === "grid" &&
|
targetHeight={targetHeight}
|
||||||
!(layout.grid.length === 2 && layout.spotlight === undefined)
|
className={className}
|
||||||
) {
|
style={style}
|
||||||
return (
|
showSpeakingIndicators={showSpeakingIndicators}
|
||||||
<>
|
|
||||||
<Grid
|
|
||||||
className={styles.scrollingGrid}
|
|
||||||
model={layout}
|
|
||||||
system={scrolling}
|
|
||||||
Tile={GridTileView}
|
|
||||||
/>
|
/>
|
||||||
<Grid
|
|
||||||
className={styles.fixedGrid}
|
|
||||||
style={{ insetBlockStart: headerBounds.bottom }}
|
|
||||||
model={layout}
|
|
||||||
system={fixed}
|
|
||||||
Tile={SpotlightTileView}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<LegacyGrid
|
|
||||||
items={items}
|
|
||||||
layout={legacyLayout}
|
|
||||||
disableAnimations={prefersReducedMotion}
|
|
||||||
Tile={GridTileView}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
|
||||||
rtcSession.room.roomId,
|
|
||||||
);
|
|
||||||
|
|
||||||
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} />);
|
),
|
||||||
}
|
[toggleFullscreen, openProfile, showSpeakingIndicators],
|
||||||
|
);
|
||||||
|
|
||||||
buttons.push(
|
const renderContent = (): JSX.Element => {
|
||||||
<HangupButton
|
if (items.length === 0) {
|
||||||
key="6"
|
return (
|
||||||
onPress={function (): void {
|
<div className={styles.centerMessage}>
|
||||||
onLeave();
|
<p>{t("waiting_for_participants")}</p>
|
||||||
}}
|
|
||||||
data-testid="incall_leave"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
footer = (
|
|
||||||
<div
|
|
||||||
ref={footerRef}
|
|
||||||
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={legacyLayout}
|
|
||||||
setLayout={setGridMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (maximisedParticipant !== null) {
|
||||||
<div className={styles.inRoom} ref={containerRef}>
|
const fullscreen = maximisedParticipant === fullscreenItem;
|
||||||
{!hideHeader && maximisedParticipant === null && (
|
if (maximisedParticipant.id === "spotlight") {
|
||||||
<Header className={styles.header} ref={headerRef}>
|
return (
|
||||||
<LeftNav>
|
<SpotlightTile
|
||||||
<RoomHeaderInfo
|
vms={layout.spotlight!}
|
||||||
id={matrixInfo.roomId}
|
maximised={true}
|
||||||
name={matrixInfo.roomName}
|
fullscreen={fullscreen}
|
||||||
avatarUrl={matrixInfo.roomAvatar}
|
onToggleFullscreen={toggleSpotlightFullscreen}
|
||||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
targetWidth={gridBounds.height}
|
||||||
participantCount={participantCount}
|
targetHeight={gridBounds.width}
|
||||||
/>
|
/>
|
||||||
</LeftNav>
|
);
|
||||||
<RightNav>
|
}
|
||||||
{!reducedControls && showControls && onShareClick !== null && (
|
return (
|
||||||
<InviteButton onClick={onShareClick} />
|
<GridTile
|
||||||
)}
|
vm={maximisedParticipant.data}
|
||||||
</RightNav>
|
maximised={true}
|
||||||
</Header>
|
fullscreen={fullscreen}
|
||||||
)}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
<RoomAudioRenderer />
|
targetHeight={gridBounds.height}
|
||||||
{renderContent()}
|
targetWidth={gridBounds.width}
|
||||||
{footer}
|
key={maximisedParticipant.id}
|
||||||
{!noControls && (
|
showSpeakingIndicators={false}
|
||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
onOpenProfile={openProfile}
|
||||||
)}
|
|
||||||
<SettingsModal
|
|
||||||
client={client}
|
|
||||||
roomId={rtcSession.room.roomId}
|
|
||||||
open={settingsModalOpen}
|
|
||||||
onDismiss={closeSettings}
|
|
||||||
tab={settingsTab}
|
|
||||||
onTabChange={setSettingsTab}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only new layout we've implemented so far is grid layout for non-1:1
|
||||||
|
// calls. All other layouts use the legacy grid system for now.
|
||||||
|
if (
|
||||||
|
legacyLayout === "grid" &&
|
||||||
|
layout.type === "grid" &&
|
||||||
|
!(layout.grid.length === 2 && layout.spotlight === undefined)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid
|
||||||
|
className={styles.scrollingGrid}
|
||||||
|
model={layout}
|
||||||
|
system={scrolling}
|
||||||
|
Tile={GridTileView}
|
||||||
|
/>
|
||||||
|
<Grid
|
||||||
|
className={styles.fixedGrid}
|
||||||
|
style={{ insetBlockStart: headerBounds.bottom }}
|
||||||
|
model={layout}
|
||||||
|
system={fixed}
|
||||||
|
Tile={SpotlightTileView}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<LegacyGrid
|
||||||
|
items={items}
|
||||||
|
layout={legacyLayout}
|
||||||
|
disableAnimations={prefersReducedMotion}
|
||||||
|
Tile={GridTileView}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||||
|
rtcSession.room.roomId,
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
ref={footerRef}
|
||||||
|
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={legacyLayout}
|
||||||
|
setLayout={setGridMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
|
return (
|
||||||
|
<div className={styles.inRoom} ref={containerRef}>
|
||||||
|
{!hideHeader && maximisedParticipant === null && (
|
||||||
|
<Header className={styles.header} ref={headerRef}>
|
||||||
|
<LeftNav>
|
||||||
|
<RoomHeaderInfo
|
||||||
|
id={matrixInfo.roomId}
|
||||||
|
name={matrixInfo.roomName}
|
||||||
|
avatarUrl={matrixInfo.roomAvatar}
|
||||||
|
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||||
|
participantCount={participantCount}
|
||||||
|
/>
|
||||||
|
</LeftNav>
|
||||||
|
<RightNav>
|
||||||
|
{!reducedControls && showControls && onShareClick !== null && (
|
||||||
|
<InviteButton onClick={onShareClick} />
|
||||||
|
)}
|
||||||
|
</RightNav>
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
{renderContent()}
|
||||||
|
{footer}
|
||||||
|
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||||
|
<SettingsModal
|
||||||
|
client={client}
|
||||||
|
roomId={rtcSession.room.roomId}
|
||||||
|
open={settingsModalOpen}
|
||||||
|
onDismiss={closeSettings}
|
||||||
|
tab={settingsTab}
|
||||||
|
onTabChange={setSettingsTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
timer,
|
timer,
|
||||||
zip,
|
zip,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { StateObservable, state } from "@react-rxjs/core";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import { ViewModel } from "./ViewModel";
|
import { ViewModel } from "./ViewModel";
|
||||||
@@ -183,7 +182,7 @@ class UserMedia {
|
|||||||
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
||||||
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipeState(
|
this.speaker = this.vm.speaking.pipe(
|
||||||
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||||
// continuous silence to stop being considered a speaker
|
// continuous silence to stop being considered a speaker
|
||||||
audit((s) =>
|
audit((s) =>
|
||||||
@@ -259,9 +258,9 @@ function findMatrixMember(
|
|||||||
|
|
||||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||||
export class CallViewModel extends ViewModel {
|
export class CallViewModel extends ViewModel {
|
||||||
private readonly rawRemoteParticipants = state(
|
private readonly rawRemoteParticipants = connectedParticipantsObserver(
|
||||||
connectedParticipantsObserver(this.livekitRoom),
|
this.livekitRoom,
|
||||||
);
|
).pipe(shareReplay(1));
|
||||||
|
|
||||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||||
// they've left
|
// they've left
|
||||||
@@ -334,78 +333,66 @@ export class CallViewModel extends ViewModel {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly mediaItems: StateObservable<MediaItem[]> = state(
|
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
|
||||||
combineLatest([
|
this.remoteParticipants,
|
||||||
this.remoteParticipants,
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
duplicateTiles.value,
|
||||||
duplicateTiles.value,
|
]).pipe(
|
||||||
]).pipe(
|
scan(
|
||||||
scan(
|
(
|
||||||
(
|
prevItems,
|
||||||
prevItems,
|
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
|
||||||
[
|
) => {
|
||||||
remoteParticipants,
|
let allGhosts = true;
|
||||||
{ participant: localParticipant },
|
|
||||||
duplicateTiles,
|
|
||||||
],
|
|
||||||
) => {
|
|
||||||
let allGhosts = true;
|
|
||||||
|
|
||||||
const newItems = new Map(
|
const newItems = new Map(
|
||||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||||
for (const p of [localParticipant, ...remoteParticipants]) {
|
for (const p of [localParticipant, ...remoteParticipants]) {
|
||||||
const member = findMatrixMember(this.matrixRoom, p.identity);
|
const member = findMatrixMember(this.matrixRoom, p.identity);
|
||||||
allGhosts &&= member === undefined;
|
allGhosts &&= member === undefined;
|
||||||
// We always start with a local participant with the empty string as
|
// We always start with a local participant with the empty string as
|
||||||
// their ID before we're connected, this is fine and we'll be in
|
// their ID before we're connected, this is fine and we'll be in
|
||||||
// "all ghosts" mode.
|
// "all ghosts" mode.
|
||||||
if (p.identity !== "" && member === undefined) {
|
if (p.identity !== "" && member === undefined) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create as many tiles for this participant as called for by
|
// Create as many tiles for this participant as called for by
|
||||||
// the duplicateTiles option
|
// the duplicateTiles option
|
||||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
||||||
const userMediaId = `${p.identity}:${i}`;
|
const userMediaId = `${p.identity}:${i}`;
|
||||||
|
yield [
|
||||||
|
userMediaId,
|
||||||
|
prevItems.get(userMediaId) ??
|
||||||
|
new UserMedia(userMediaId, member, p, this.encrypted),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (p.isScreenShareEnabled) {
|
||||||
|
const screenShareId = `${userMediaId}:screen-share`;
|
||||||
yield [
|
yield [
|
||||||
userMediaId,
|
screenShareId,
|
||||||
prevItems.get(userMediaId) ??
|
prevItems.get(screenShareId) ??
|
||||||
new UserMedia(userMediaId, member, p, this.encrypted),
|
new ScreenShare(screenShareId, member, p, this.encrypted),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (p.isScreenShareEnabled) {
|
|
||||||
const screenShareId = `${userMediaId}:screen-share`;
|
|
||||||
yield [
|
|
||||||
screenShareId,
|
|
||||||
prevItems.get(screenShareId) ??
|
|
||||||
new ScreenShare(
|
|
||||||
screenShareId,
|
|
||||||
member,
|
|
||||||
p,
|
|
||||||
this.encrypted,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.bind(this)(),
|
}
|
||||||
);
|
}.bind(this)(),
|
||||||
|
);
|
||||||
|
|
||||||
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
|
// If every item is a ghost, that probably means we're still connecting
|
||||||
|
// and shouldn't bother showing anything yet
|
||||||
// If every item is a ghost, that probably means we're still connecting
|
return allGhosts ? new Map() : newItems;
|
||||||
// and shouldn't bother showing anything yet
|
},
|
||||||
return allGhosts ? new Map() : newItems;
|
new Map<string, MediaItem>(),
|
||||||
},
|
|
||||||
new Map<string, MediaItem>(),
|
|
||||||
),
|
|
||||||
map((mediaItems) => [...mediaItems.values()]),
|
|
||||||
finalizeValue((ts) => {
|
|
||||||
for (const t of ts) t.destroy();
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
map((mediaItems) => [...mediaItems.values()]),
|
||||||
|
finalizeValue((ts) => {
|
||||||
|
for (const t of ts) t.destroy();
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
|
||||||
@@ -507,14 +494,15 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* The layout mode of the media tile grid.
|
||||||
*/
|
*/
|
||||||
public readonly gridMode = state(this._gridMode);
|
public readonly gridMode: Observable<GridMode> = this._gridMode;
|
||||||
|
|
||||||
public setGridMode(value: GridMode): void {
|
public setGridMode(value: GridMode): void {
|
||||||
this._gridMode.next(value);
|
this._gridMode.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly layout: StateObservable<Layout> = state(
|
public readonly layout: Observable<Layout> = combineLatest(
|
||||||
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
|
[this._gridMode, this.windowMode],
|
||||||
|
(gridMode, windowMode) => {
|
||||||
switch (windowMode) {
|
switch (windowMode) {
|
||||||
case "full screen":
|
case "full screen":
|
||||||
throw new Error("unimplemented");
|
throw new Error("unimplemented");
|
||||||
@@ -543,110 +531,109 @@ export class CallViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).pipe(switchAll()),
|
},
|
||||||
);
|
).pipe(switchAll(), shareReplay(1));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The media tiles to be displayed in the call view.
|
* The media tiles to be displayed in the call view.
|
||||||
*/
|
*/
|
||||||
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
// TODO: Get rid of this field, replacing it with the 'layout' field above
|
||||||
// which keeps more details of the layout order internal to the view model
|
// which keeps more details of the layout order internal to the view model
|
||||||
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> =
|
public readonly tiles: Observable<TileDescriptor<MediaViewModel>[]> =
|
||||||
state(
|
combineLatest([
|
||||||
combineLatest([
|
this.remoteParticipants,
|
||||||
this.remoteParticipants,
|
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
]).pipe(
|
||||||
]).pipe(
|
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
||||||
scan((ts, [remoteParticipants, { participant: localParticipant }]) => {
|
const ps = [localParticipant, ...remoteParticipants];
|
||||||
const ps = [localParticipant, ...remoteParticipants];
|
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
||||||
const tilesById = new Map(ts.map((t) => [t.id, t]));
|
const now = Date.now();
|
||||||
const now = Date.now();
|
let allGhosts = true;
|
||||||
let allGhosts = true;
|
|
||||||
|
|
||||||
const newTiles = ps.flatMap((p) => {
|
const newTiles = ps.flatMap((p) => {
|
||||||
const userMediaId = p.identity;
|
const userMediaId = p.identity;
|
||||||
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
const member = findMatrixMember(this.matrixRoom, userMediaId);
|
||||||
allGhosts &&= member === undefined;
|
allGhosts &&= member === undefined;
|
||||||
const spokeRecently =
|
const spokeRecently =
|
||||||
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000;
|
||||||
|
|
||||||
// We always start with a local participant with the empty string as
|
// We always start with a local participant with the empty string as
|
||||||
// their ID before we're connected, this is fine and we'll be in
|
// their ID before we're connected, this is fine and we'll be in
|
||||||
// "all ghosts" mode.
|
// "all ghosts" mode.
|
||||||
if (userMediaId !== "" && member === undefined) {
|
if (userMediaId !== "" && member === undefined) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
`Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMediaVm =
|
const userMediaVm =
|
||||||
tilesById.get(userMediaId)?.data ??
|
tilesById.get(userMediaId)?.data ??
|
||||||
(p instanceof LocalParticipant
|
(p instanceof LocalParticipant
|
||||||
? new LocalUserMediaViewModel(
|
? new LocalUserMediaViewModel(
|
||||||
userMediaId,
|
userMediaId,
|
||||||
member,
|
|
||||||
p,
|
|
||||||
this.encrypted,
|
|
||||||
)
|
|
||||||
: new RemoteUserMediaViewModel(
|
|
||||||
userMediaId,
|
|
||||||
member,
|
|
||||||
p,
|
|
||||||
this.encrypted,
|
|
||||||
));
|
|
||||||
tilesById.delete(userMediaId);
|
|
||||||
|
|
||||||
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
|
||||||
id: userMediaId,
|
|
||||||
focused: false,
|
|
||||||
isPresenter: p.isScreenShareEnabled,
|
|
||||||
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
|
||||||
hasVideo: p.isCameraEnabled,
|
|
||||||
local: p.isLocal,
|
|
||||||
largeBaseSize: false,
|
|
||||||
data: userMediaVm,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (p.isScreenShareEnabled) {
|
|
||||||
const screenShareId = `${userMediaId}:screen-share`;
|
|
||||||
const screenShareVm =
|
|
||||||
tilesById.get(screenShareId)?.data ??
|
|
||||||
new ScreenShareViewModel(
|
|
||||||
screenShareId,
|
|
||||||
member,
|
member,
|
||||||
p,
|
p,
|
||||||
this.encrypted,
|
this.encrypted,
|
||||||
);
|
)
|
||||||
tilesById.delete(screenShareId);
|
: new RemoteUserMediaViewModel(
|
||||||
|
userMediaId,
|
||||||
|
member,
|
||||||
|
p,
|
||||||
|
this.encrypted,
|
||||||
|
));
|
||||||
|
tilesById.delete(userMediaId);
|
||||||
|
|
||||||
const screenShareTile: TileDescriptor<MediaViewModel> = {
|
const userMediaTile: TileDescriptor<MediaViewModel> = {
|
||||||
id: screenShareId,
|
id: userMediaId,
|
||||||
focused: true,
|
focused: false,
|
||||||
isPresenter: false,
|
isPresenter: p.isScreenShareEnabled,
|
||||||
isSpeaker: false,
|
isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal,
|
||||||
hasVideo: true,
|
hasVideo: p.isCameraEnabled,
|
||||||
local: p.isLocal,
|
local: p.isLocal,
|
||||||
largeBaseSize: true,
|
largeBaseSize: false,
|
||||||
placeNear: userMediaId,
|
data: userMediaVm,
|
||||||
data: screenShareVm,
|
};
|
||||||
};
|
|
||||||
return [userMediaTile, screenShareTile];
|
|
||||||
} else {
|
|
||||||
return [userMediaTile];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Any tiles left in the map are unused and should be destroyed
|
if (p.isScreenShareEnabled) {
|
||||||
for (const t of tilesById.values()) t.data.destroy();
|
const screenShareId = `${userMediaId}:screen-share`;
|
||||||
|
const screenShareVm =
|
||||||
|
tilesById.get(screenShareId)?.data ??
|
||||||
|
new ScreenShareViewModel(
|
||||||
|
screenShareId,
|
||||||
|
member,
|
||||||
|
p,
|
||||||
|
this.encrypted,
|
||||||
|
);
|
||||||
|
tilesById.delete(screenShareId);
|
||||||
|
|
||||||
// If every item is a ghost, that probably means we're still connecting
|
const screenShareTile: TileDescriptor<MediaViewModel> = {
|
||||||
// and shouldn't bother showing anything yet
|
id: screenShareId,
|
||||||
return allGhosts ? [] : newTiles;
|
focused: true,
|
||||||
}, [] as TileDescriptor<MediaViewModel>[]),
|
isPresenter: false,
|
||||||
finalizeValue((ts) => {
|
isSpeaker: false,
|
||||||
for (const t of ts) t.data.destroy();
|
hasVideo: true,
|
||||||
}),
|
local: p.isLocal,
|
||||||
),
|
largeBaseSize: true,
|
||||||
|
placeNear: userMediaId,
|
||||||
|
data: screenShareVm,
|
||||||
|
};
|
||||||
|
return [userMediaTile, screenShareTile];
|
||||||
|
} else {
|
||||||
|
return [userMediaTile];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any tiles left in the map are unused and should be destroyed
|
||||||
|
for (const t of tilesById.values()) t.data.destroy();
|
||||||
|
|
||||||
|
// If every item is a ghost, that probably means we're still connecting
|
||||||
|
// and shouldn't bother showing anything yet
|
||||||
|
return allGhosts ? [] : newTiles;
|
||||||
|
}, [] as TileDescriptor<MediaViewModel>[]),
|
||||||
|
finalizeValue((ts) => {
|
||||||
|
for (const t of ts) t.data.destroy();
|
||||||
|
}),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import { StateObservable, state } from "@react-rxjs/core";
|
|
||||||
import {
|
import {
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
LocalTrack,
|
LocalTrack,
|
||||||
@@ -35,12 +34,14 @@ import {
|
|||||||
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
distinctUntilKeyChanged,
|
distinctUntilKeyChanged,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
of,
|
of,
|
||||||
|
shareReplay,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
@@ -92,16 +93,15 @@ export function useNameData(vm: MediaViewModel): NameData {
|
|||||||
function observeTrackReference(
|
function observeTrackReference(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): StateObservable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReferenceOrPlaceholder> {
|
||||||
return state(
|
return observeParticipantMedia(participant).pipe(
|
||||||
observeParticipantMedia(participant).pipe(
|
map(() => ({
|
||||||
map(() => ({
|
participant,
|
||||||
participant,
|
publication: participant.getTrackPublication(source),
|
||||||
publication: participant.getTrackPublication(source),
|
source,
|
||||||
source,
|
})),
|
||||||
})),
|
distinctUntilKeyChanged("publication"),
|
||||||
distinctUntilKeyChanged("publication"),
|
shareReplay(1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
public readonly video: StateObservable<TrackReferenceOrPlaceholder>;
|
public readonly video: Observable<TrackReferenceOrPlaceholder>;
|
||||||
/**
|
/**
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
*/
|
*/
|
||||||
public readonly unencryptedWarning: StateObservable<boolean>;
|
public readonly unencryptedWarning: Observable<boolean>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
/**
|
/**
|
||||||
@@ -138,15 +138,13 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
super();
|
super();
|
||||||
const audio = observeTrackReference(participant, audioSource);
|
const audio = observeTrackReference(participant, audioSource);
|
||||||
this.video = observeTrackReference(participant, videoSource);
|
this.video = observeTrackReference(participant, videoSource);
|
||||||
this.unencryptedWarning = state(
|
this.unencryptedWarning = combineLatest(
|
||||||
combineLatest(
|
[audio, this.video],
|
||||||
[audio, this.video],
|
(a, v) =>
|
||||||
(a, v) =>
|
callEncrypted &&
|
||||||
callEncrypted &&
|
(a.publication?.isEncrypted === false ||
|
||||||
(a.publication?.isEncrypted === false ||
|
v.publication?.isEncrypted === false),
|
||||||
v.publication?.isEncrypted === false),
|
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||||
).pipe(distinctUntilChanged()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,27 +163,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether the participant is speaking.
|
* Whether the participant is speaking.
|
||||||
*/
|
*/
|
||||||
public readonly speaking = state(
|
public readonly speaking = observeParticipantEvents(
|
||||||
observeParticipantEvents(
|
this.participant,
|
||||||
this.participant,
|
ParticipantEvent.IsSpeakingChanged,
|
||||||
ParticipantEvent.IsSpeakingChanged,
|
).pipe(
|
||||||
).pipe(map((p) => p.isSpeaking)),
|
map((p) => p.isSpeaking),
|
||||||
|
shareReplay(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||||
*/
|
*/
|
||||||
public readonly audioEnabled: StateObservable<boolean>;
|
public readonly audioEnabled: Observable<boolean>;
|
||||||
/**
|
/**
|
||||||
* Whether this participant is sending video.
|
* Whether this participant is sending video.
|
||||||
*/
|
*/
|
||||||
public readonly videoEnabled: StateObservable<boolean>;
|
public readonly videoEnabled: Observable<boolean>;
|
||||||
|
|
||||||
private readonly _cropVideo = new BehaviorSubject(true);
|
private readonly _cropVideo = new BehaviorSubject(true);
|
||||||
/**
|
/**
|
||||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||||
*/
|
*/
|
||||||
public readonly cropVideo = state(this._cropVideo);
|
public readonly cropVideo: Observable<boolean> = this._cropVideo;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -202,12 +201,12 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media = observeParticipantMedia(participant);
|
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
|
||||||
this.audioEnabled = state(
|
this.audioEnabled = media.pipe(
|
||||||
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)),
|
map((m) => m.microphoneTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
this.videoEnabled = state(
|
this.videoEnabled = media.pipe(
|
||||||
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
|
map((m) => m.cameraTrack?.isMuted === false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,19 +222,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether the video should be mirrored.
|
* Whether the video should be mirrored.
|
||||||
*/
|
*/
|
||||||
public readonly mirror = state(
|
public readonly mirror = this.video.pipe(
|
||||||
this.video.pipe(
|
switchMap((v) => {
|
||||||
switchMap((v) => {
|
const track = v.publication?.track;
|
||||||
const track = v.publication?.track;
|
if (!(track instanceof LocalTrack)) return of(false);
|
||||||
if (!(track instanceof LocalTrack)) return of(false);
|
// Watch for track restarts, because they indicate a camera switch
|
||||||
// Watch for track restarts, because they indicate a camera switch
|
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
startWith(null),
|
||||||
startWith(null),
|
// Mirror only front-facing cameras (those that face the user)
|
||||||
// Mirror only front-facing cameras (those that face the user)
|
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
shareReplay(1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,14 +261,14 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether we've disabled this participant's audio.
|
* Whether we've disabled this participant's audio.
|
||||||
*/
|
*/
|
||||||
public readonly locallyMuted = state(this._locallyMuted);
|
public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
|
||||||
|
|
||||||
private readonly _localVolume = new BehaviorSubject(1);
|
private readonly _localVolume = new BehaviorSubject(1);
|
||||||
/**
|
/**
|
||||||
* The volume to which we've set this participant's audio, as a scalar
|
* The volume to which we've set this participant's audio, as a scalar
|
||||||
* multiplier.
|
* multiplier.
|
||||||
*/
|
*/
|
||||||
public readonly localVolume = state(this._localVolume);
|
public readonly localVolume: Observable<number> = this._localVolume;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
/*
|
|
||||||
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 {
|
|
||||||
ForwardRefExoticComponent,
|
|
||||||
ForwardRefRenderFunction,
|
|
||||||
PropsWithoutRef,
|
|
||||||
RefAttributes,
|
|
||||||
forwardRef,
|
|
||||||
} 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<P, R>(
|
|
||||||
render: ForwardRefRenderFunction<R, P>,
|
|
||||||
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>> {
|
|
||||||
const Subscriber = forwardRef<R, { p: P }>(({ p }, ref) => (
|
|
||||||
<RemoveSubscribe>{render(p, ref)}</RemoveSubscribe>
|
|
||||||
));
|
|
||||||
Subscriber.displayName = "Subscriber";
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
|
||||||
const OuterComponent = forwardRef<R, P>((p, ref) => (
|
|
||||||
<Subscribe>
|
|
||||||
<Subscriber ref={ref} p={p} />
|
|
||||||
</Subscribe>
|
|
||||||
));
|
|
||||||
// Copy over the component's display name, default props, etc.
|
|
||||||
Object.assign(OuterComponent, render);
|
|
||||||
return OuterComponent;
|
|
||||||
}
|
|
||||||
@@ -28,14 +28,13 @@ import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?r
|
|||||||
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
|
||||||
import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react";
|
import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { state, useStateObservable } from "@react-rxjs/core";
|
|
||||||
import { Observable, map, of } from "rxjs";
|
import { Observable, map, of } from "rxjs";
|
||||||
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import styles from "./SpotlightTile.module.css";
|
import styles from "./SpotlightTile.module.css";
|
||||||
import { subscribe } from "../state/subscribe";
|
|
||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
MediaViewModel,
|
MediaViewModel,
|
||||||
@@ -49,11 +48,11 @@ import { useReactiveState } from "../useReactiveState";
|
|||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
// Screen share video is always enabled
|
// Screen share video is always enabled
|
||||||
const videoEnabledDefault = state(of(true));
|
const videoEnabledDefault = of(true);
|
||||||
// Never mirror screen share video
|
// Never mirror screen share video
|
||||||
const mirrorDefault = state(of(false));
|
const mirrorDefault = of(false);
|
||||||
// Never crop screen share video
|
// Never crop screen share video
|
||||||
const cropVideoDefault = state(of(false));
|
const cropVideoDefault = of(false);
|
||||||
|
|
||||||
interface SpotlightItemProps {
|
interface SpotlightItemProps {
|
||||||
vm: MediaViewModel;
|
vm: MediaViewModel;
|
||||||
@@ -66,28 +65,28 @@ interface SpotlightItemProps {
|
|||||||
snap: boolean;
|
snap: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>(
|
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||||
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const { displayName, nameTag } = useNameData(vm);
|
const { displayName, nameTag } = useNameData(vm);
|
||||||
const video = useStateObservable(vm.video);
|
const video = useObservableEagerState(vm.video);
|
||||||
const videoEnabled = useStateObservable(
|
const videoEnabled = useObservableEagerState(
|
||||||
vm instanceof LocalUserMediaViewModel ||
|
vm instanceof LocalUserMediaViewModel ||
|
||||||
vm instanceof RemoteUserMediaViewModel
|
vm instanceof RemoteUserMediaViewModel
|
||||||
? vm.videoEnabled
|
? vm.videoEnabled
|
||||||
: videoEnabledDefault,
|
: videoEnabledDefault,
|
||||||
);
|
);
|
||||||
const mirror = useStateObservable(
|
const mirror = useObservableEagerState(
|
||||||
vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault,
|
vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault,
|
||||||
);
|
);
|
||||||
const cropVideo = useStateObservable(
|
const cropVideo = useObservableEagerState(
|
||||||
vm instanceof LocalUserMediaViewModel ||
|
vm instanceof LocalUserMediaViewModel ||
|
||||||
vm instanceof RemoteUserMediaViewModel
|
vm instanceof RemoteUserMediaViewModel
|
||||||
? vm.cropVideo
|
? vm.cropVideo
|
||||||
: cropVideoDefault,
|
: cropVideoDefault,
|
||||||
);
|
);
|
||||||
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||||
|
|
||||||
// Hook this item up to the intersection observer
|
// Hook this item up to the intersection observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,6 +123,8 @@ const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
SpotlightItem.displayName = "SpotlightItem";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vms: MediaViewModel[];
|
vms: MediaViewModel[];
|
||||||
maximised: boolean;
|
maximised: boolean;
|
||||||
|
|||||||
18
yarn.lock
18
yarn.lock
@@ -2847,14 +2847,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
|
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
|
||||||
integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==
|
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":
|
"@react-spring/animated@~9.7.3":
|
||||||
version "9.7.3"
|
version "9.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
|
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
|
||||||
@@ -3194,11 +3186,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4"
|
||||||
integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==
|
integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==
|
||||||
|
|
||||||
"@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/browser-utils@8.13.0":
|
"@sentry-internal/browser-utils@8.13.0":
|
||||||
version "8.13.0"
|
version "8.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.13.0.tgz#b7c3bdd49d2382f60dde31745716d29dd419b6ba"
|
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.13.0.tgz#b7c3bdd49d2382f60dde31745716d29dd419b6ba"
|
||||||
@@ -8988,11 +8975,6 @@ use-sidecar@^1.1.2:
|
|||||||
detect-node-es "^1.1.0"
|
detect-node-es "^1.1.0"
|
||||||
tslib "^2.0.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.16.0:
|
usehooks-ts@2.16.0:
|
||||||
version "2.16.0"
|
version "2.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.16.0.tgz#31deaa2f1147f65666aae925bd890b54e63b0d3f"
|
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.16.0.tgz#31deaa2f1147f65666aae925bd890b54e63b0d3f"
|
||||||
|
|||||||
Reference in New Issue
Block a user