Merge pull request #2381 from robintown/observable-hooks

Replace react-rxjs with observable-hooks
This commit is contained in:
Robin
2024-07-17 15:56:31 -04:00
committed by GitHub
9 changed files with 630 additions and 734 deletions

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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"