From ac748cda935ecb79bb3d1c1c8e8d7eed3970403c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 16 May 2024 15:23:10 -0400 Subject: [PATCH] Replace react-rxjs with observable-hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-rxjs is the library we've been using to connect our React components to view models and consume observables. However, after spending some time with react-rxjs, I feel that it's a very heavy-handed solution. It requires us to sprinkle and components all throughout the code, and makes React go through an extra render cycle whenever we mount a component that binds to a view model. What I really want is a lightweight React hook that just gets the current value out of a plain observable, without any extra setup. Luckily the observable-hooks library with its useObservableEagerState hook seems to do just that—and it's more actively maintained, too! --- .eslintrc.cjs | 9 - package.json | 1 - src/grid/GridLayout.tsx | 29 +- src/room/InCallView.tsx | 816 ++++++++++++++++++------------------ src/state/CallViewModel.ts | 303 +++++++------ src/state/MediaViewModel.ts | 100 +++-- src/state/subscribe.tsx | 49 --- src/tile/SpotlightTile.tsx | 23 +- yarn.lock | 18 - 9 files changed, 626 insertions(+), 722 deletions(-) delete mode 100644 src/state/subscribe.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f6a2e5693..5970790f0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -38,15 +38,6 @@ module.exports = { "jsx-a11y/media-has-caption": "off", // We should use the js-sdk logger, never console directly. "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", }, settings: { diff --git a/package.json b/package.json index c2fd7848b..5d4d064fb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@react-aria/tabs": "^3.1.0", "@react-aria/tooltip": "^3.1.3", "@react-aria/utils": "^3.10.0", - "@react-rxjs/core": "^0.10.7", "@react-spring/web": "^9.4.4", "@react-stately/collections": "^3.3.4", "@react-stately/select": "^3.1.3", diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index 8df7753bd..6b673e644 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -14,16 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { CSSProperties, useMemo } from "react"; -import { StateObservable, state, useStateObservable } from "@react-rxjs/core"; -import { BehaviorSubject, distinctUntilChanged } from "rxjs"; +import { CSSProperties, forwardRef, useMemo } from "react"; +import { BehaviorSubject, Observable, distinctUntilChanged } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; import { MediaViewModel } from "../state/MediaViewModel"; import { LayoutSystem, Slot } from "./Grid"; import styles from "./GridLayout.module.css"; import { useReactiveState } from "../useReactiveState"; -import { subscribe } from "../state/subscribe"; import { Alignment } from "../room/InCallView"; import { useInitial } from "../useInitial"; @@ -48,7 +47,7 @@ const slotMaxAspectRatio = 17 / 9; const slotMinAspectRatio = 4 / 3; export const gridLayoutSystems = ( - minBounds: StateObservable, + minBounds: Observable, floatingAlignment: BehaviorSubject, ): GridLayoutSystems => ({ // The "fixed" (non-scrolling) part of the layout is where the spotlight tile @@ -58,15 +57,13 @@ export const gridLayoutSystems = ( new Map( model.spotlight === undefined ? [] : [["spotlight", model.spotlight]], ), - Layout: subscribe(function GridLayoutFixed({ model }, ref) { - const { width, height } = useStateObservable(minBounds); - const alignment = useStateObservable( - useInitial>(() => - state( - floatingAlignment.pipe( - distinctUntilChanged( - (a1, a2) => a1.block === a2.block && a1.inline === a2.inline, - ), + Layout: forwardRef(function GridLayoutFixed({ model }, ref) { + const { width, height } = useObservableEagerState(minBounds); + const alignment = useObservableEagerState( + useInitial(() => + floatingAlignment.pipe( + distinctUntilChanged( + (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 scrolling: { tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), - Layout: subscribe(function GridLayout({ model }, ref) { - const { width, height: minHeight } = useStateObservable(minBounds); + Layout: forwardRef(function GridLayout({ model }, ref) { + const { width, height: minHeight } = useObservableEagerState(minBounds); // 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 diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 1841ed032..995bb6b75 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -35,8 +35,8 @@ import { import useMeasure from "react-use-measure"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import classNames from "classnames"; -import { state, useStateObservable } from "@react-rxjs/core"; import { BehaviorSubject } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { useTranslation } from "react-i18next"; import LogoMark from "../icons/LogoMark.svg?react"; @@ -76,7 +76,6 @@ import { TileDescriptor, useCallViewModel, } from "../state/CallViewModel"; -import { subscribe } from "../state/subscribe"; import { Grid, TileProps } from "../grid/Grid"; import { MediaViewModel } from "../state/MediaViewModel"; import { gridLayoutSystems } from "../grid/GridLayout"; @@ -143,458 +142,449 @@ export interface InCallViewProps { onShareClick: (() => void) | null; } -export const InCallView: FC = subscribe( - ({ - client, - matrixInfo, - rtcSession, - livekitRoom, - muteStates, - participantCount, - onLeave, - hideHeader, - otelGroupCallMembership, - connState, - onShareClick, - }) => { - const { t } = useTranslation(); - usePreventScroll(); - useWakeLock(); - - useEffect(() => { - if (connState === ConnectionState.Disconnected) { - // annoyingly we don't get the disconnection reason this way, - // only by listening for the emitted event - onLeave(new Error("Disconnected from call server")); - } - }, [connState, onLeave]); - - const containerRef1 = useRef(null); - const [containerRef2, bounds] = useMeasure(); - const boundsValid = bounds.height > 0; - // Merge the refs so they can attach to the same element - const containerRef = useMergedRefs(containerRef1, containerRef2); - - const screenSharingTracks = useTracks( - [{ source: Track.Source.ScreenShare, withPlaceholder: false }], - { - room: livekitRoom, - }, - ); - const { layout: legacyLayout, setLayout: setLegacyLayout } = - useLegacyGridLayout(screenSharingTracks.length > 0); +export const InCallView: FC = ({ + client, + matrixInfo, + rtcSession, + livekitRoom, + muteStates, + participantCount, + onLeave, + hideHeader, + otelGroupCallMembership, + connState, + onShareClick, +}) => { + const { t } = useTranslation(); + usePreventScroll(); + useWakeLock(); + + useEffect(() => { + if (connState === ConnectionState.Disconnected) { + // annoyingly we don't get the disconnection reason this way, + // only by listening for the emitted event + onLeave(new Error("Disconnected from call server")); + } + }, [connState, onLeave]); - const { hideScreensharing, showControls } = useUrlParams(); + const containerRef1 = useRef(null); + const [containerRef2, bounds] = useMeasure(); + const boundsValid = bounds.height > 0; + // Merge the refs so they can attach to the same element + const containerRef = useMergedRefs(containerRef1, containerRef2); - const { isScreenShareEnabled, localParticipant } = useLocalParticipant({ + const screenSharingTracks = useTracks( + [{ source: Track.Source.ScreenShare, withPlaceholder: false }], + { room: livekitRoom, - }); + }, + ); + const { layout: legacyLayout, setLayout: setLegacyLayout } = + useLegacyGridLayout(screenSharingTracks.length > 0); - const toggleMicrophone = useCallback( - () => muteStates.audio.setEnabled?.((e) => !e), - [muteStates], - ); - const toggleCamera = useCallback( - () => muteStates.video.setEnabled?.((e) => !e), - [muteStates], - ); + const { hideScreensharing, showControls } = useUrlParams(); + + const { isScreenShareEnabled, localParticipant } = useLocalParticipant({ + room: livekitRoom, + }); + + const toggleMicrophone = useCallback( + () => muteStates.audio.setEnabled?.((e) => !e), + [muteStates], + ); + const toggleCamera = useCallback( + () => muteStates.video.setEnabled?.((e) => !e), + [muteStates], + ); + + // This function incorrectly assumes that there is a camera and microphone, which is not always the case. + // TODO: Make sure that this module is resilient when it comes to camera/microphone availability! + useCallViewKeyboardShortcuts( + containerRef1, + toggleMicrophone, + toggleCamera, + (muted) => muteStates.audio.setEnabled?.(!muted), + ); - // 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(() => { - widget?.api.transport.send( - legacyLayout === "grid" - ? ElementWidgetActions.TileLayout - : ElementWidgetActions.SpotlightLayout, - {}, + useEffect(() => { + if (widget) { + const onTileLayout = (ev: CustomEvent): void => { + setLegacyLayout("grid"); + widget!.api.transport.reply(ev.detail, {}); + }; + const onSpotlightLayout = (ev: CustomEvent): void => { + setLegacyLayout("spotlight"); + widget!.api.transport.reply(ev.detail, {}); + }; + + widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout); + widget.lazyActions.on( + ElementWidgetActions.SpotlightLayout, + onSpotlightLayout, ); - }, [legacyLayout]); - - useEffect(() => { - if (widget) { - const onTileLayout = (ev: CustomEvent): void => { - setLegacyLayout("grid"); - widget!.api.transport.reply(ev.detail, {}); - }; - const onSpotlightLayout = ( - ev: CustomEvent, - ): void => { - setLegacyLayout("spotlight"); - widget!.api.transport.reply(ev.detail, {}); - }; - - widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout); - widget.lazyActions.on( + + return () => { + widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout); + widget!.lazyActions.off( ElementWidgetActions.SpotlightLayout, onSpotlightLayout, ); + }; + } + }, [setLegacyLayout]); - return () => { - widget!.lazyActions.off( - ElementWidgetActions.TileLayout, - onTileLayout, - ); - widget!.lazyActions.off( - ElementWidgetActions.SpotlightLayout, - onSpotlightLayout, - ); - }; - } - }, [setLegacyLayout]); - - const mobile = boundsValid && bounds.width <= 660; - const reducedControls = boundsValid && bounds.width <= 340; - const noControls = reducedControls && bounds.height <= 400; - - const vm = useCallViewModel( - rtcSession.room, - livekitRoom, - matrixInfo.e2eeSystem.kind !== E2eeType.NONE, - connState, - ); - const items = useStateObservable(vm.tiles); - const layout = useStateObservable(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( - () => [...items, ...(hasSpotlight ? [dummySpotlightItem] : [])], - [items, hasSpotlight], - ); - const { fullscreenItem, toggleFullscreen, exitFullscreen } = - useFullscreen(fullscreenItems); - const toggleSpotlightFullscreen = useCallback( - () => toggleFullscreen("spotlight"), - [toggleFullscreen], - ); - - // The maximised participant: either the participant that the user has - // manually put in fullscreen, or the focused (active) participant if the - // window is too small to show everyone - const maximisedParticipant = useMemo( - () => - fullscreenItem ?? - (noControls - ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null - : null), - [fullscreenItem, noControls, items], - ); + const mobile = boundsValid && bounds.width <= 660; + const reducedControls = boundsValid && bounds.width <= 340; + const noControls = reducedControls && bounds.height <= 400; - const prefersReducedMotion = usePrefersReducedMotion(); + const vm = useCallViewModel( + rtcSession.room, + 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( + () => [...items, ...(hasSpotlight ? [dummySpotlightItem] : [])], + [items, hasSpotlight], + ); + const { fullscreenItem, toggleFullscreen, exitFullscreen } = + useFullscreen(fullscreenItems); + const toggleSpotlightFullscreen = useCallback( + () => toggleFullscreen("spotlight"), + [toggleFullscreen], + ); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); + // The maximised participant: either the participant that the user has + // manually put in fullscreen, or the focused (active) participant if the + // window is too small to show everyone + const maximisedParticipant = useMemo( + () => + fullscreenItem ?? + (noControls + ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null + : null), + [fullscreenItem, noControls, items], + ); - const openSettings = useCallback( - () => setSettingsModalOpen(true), - [setSettingsModalOpen], - ); - const closeSettings = useCallback( - () => setSettingsModalOpen(false), - [setSettingsModalOpen], - ); + const prefersReducedMotion = usePrefersReducedMotion(); - const openProfile = useCallback(() => { - setSettingsTab("profile"); - setSettingsModalOpen(true); - }, [setSettingsTab, setSettingsModalOpen]); - - const [headerRef, headerBounds] = useMeasure(); - const [footerRef, footerBounds] = useMeasure(); - const gridBounds = useMemo( - () => ({ - 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(state(gridBoundsObservable), floatingAlignment), - ); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); - const setGridMode = useCallback( - (mode: GridMode) => { - setLegacyLayout(mode); - vm.setGridMode(mode); - }, - [setLegacyLayout, vm], - ); + const openSettings = useCallback( + () => setSettingsModalOpen(true), + [setSettingsModalOpen], + ); + const closeSettings = useCallback( + () => setSettingsModalOpen(false), + [setSettingsModalOpen], + ); - const showSpeakingIndicators = - layout.type === "spotlight" || - (layout.type === "grid" && layout.grid.length > 2); - - const SpotlightTileView = useMemo( - () => - forwardRef>( - function SpotlightTileView( - { className, style, targetWidth, targetHeight, model }, - ref, - ) { - return ( - - ); - }, - ), - [toggleSpotlightFullscreen], - ); - const GridTileView = useMemo( - () => - forwardRef>( - function GridTileView( - { className, style, targetWidth, targetHeight, model }, - ref, - ) { - return ( - - ); - }, - ), - [toggleFullscreen, openProfile, showSpeakingIndicators], - ); + const openProfile = useCallback(() => { + setSettingsTab("profile"); + setSettingsModalOpen(true); + }, [setSettingsTab, setSettingsModalOpen]); + + const [headerRef, headerBounds] = useMeasure(); + const [footerRef, footerBounds] = useMeasure(); + const gridBounds = useMemo( + () => ({ + 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 renderContent = (): JSX.Element => { - if (items.length === 0) { - return ( -
-

{t("waiting_for_participants")}

-
- ); - } + const setGridMode = useCallback( + (mode: GridMode) => { + setLegacyLayout(mode); + vm.setGridMode(mode); + }, + [setLegacyLayout, vm], + ); - if (maximisedParticipant !== null) { - const fullscreen = maximisedParticipant === fullscreenItem; - if (maximisedParticipant.id === "spotlight") { + const showSpeakingIndicators = + layout.type === "spotlight" || + (layout.type === "grid" && layout.grid.length > 2); + + const SpotlightTileView = useMemo( + () => + forwardRef>( + function SpotlightTileView( + { className, style, targetWidth, targetHeight, model }, + ref, + ) { return ( ); - } + }, + ), + [toggleSpotlightFullscreen], + ); + const GridTileView = useMemo( + () => + forwardRef>( + function GridTileView( + { className, style, targetWidth, targetHeight, model }, + ref, + ) { + return ( + + ); + }, + ), + [toggleFullscreen, openProfile, showSpeakingIndicators], + ); + + const renderContent = (): JSX.Element => { + if (items.length === 0) { + return ( +
+

{t("waiting_for_participants")}

+
+ ); + } + + if (maximisedParticipant !== null) { + const fullscreen = maximisedParticipant === fullscreenItem; + if (maximisedParticipant.id === "spotlight") { return ( - ); } + return ( + + ); + } - // 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 ( - <> - - - - ); - } else { - return ( - + - ); - } - }; - - 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( - , - , + return ( + ); + } + }; - if (!reducedControls) { - if (canScreenshare && !hideScreensharing) { - buttons.push( - , - ); - } - buttons.push(); - } + const rageshakeRequestModalProps = useRageshakeRequestModal( + rtcSession.room.roomId, + ); - buttons.push( - , - ); - footer = ( -
- {!mobile && !hideHeader && ( -
- - -
- )} - {showControls &&
{buttons}
} - {!mobile && !hideHeader && showControls && ( - - )} -
- ); + 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( + , + , + ); + + if (!reducedControls) { + if (canScreenshare && !hideScreensharing) { + buttons.push( + , + ); + } + buttons.push(); } - return ( -
- {!hideHeader && maximisedParticipant === null && ( -
- - - - - {!reducedControls && showControls && onShareClick !== null && ( - - )} - -
+ buttons.push( + , + ); + footer = ( +
- {renderContent()} - {footer} - {!noControls && ( - + > + {!mobile && !hideHeader && ( +
+ + +
+ )} + {showControls &&
{buttons}
} + {!mobile && !hideHeader && showControls && ( + )} -
); - }, -); + } + + return ( +
+ {!hideHeader && maximisedParticipant === null && ( +
+ + + + + {!reducedControls && showControls && onShareClick !== null && ( + + )} + +
+ )} + + {renderContent()} + {footer} + {!noControls && } + +
+ ); +}; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 0e7e425f3..6b4219113 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -50,7 +50,6 @@ import { timer, zip, } from "rxjs"; -import { StateObservable, state } from "@react-rxjs/core"; import { logger } from "matrix-js-sdk/src/logger"; import { ViewModel } from "./ViewModel"; @@ -159,7 +158,7 @@ class UserMedia { ? new LocalUserMediaViewModel(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 // continuous silence to stop being considered a speaker audit((s) => @@ -235,9 +234,9 @@ function findMatrixMember( // TODO: Move wayyyy more business logic from the call and lobby views into here export class CallViewModel extends ViewModel { - private readonly rawRemoteParticipants = state( - connectedParticipantsObserver(this.livekitRoom), - ); + private readonly rawRemoteParticipants = connectedParticipantsObserver( + this.livekitRoom, + ).pipe(shareReplay(1)); // Lists of participants to "hold" on display, even if LiveKit claims that // they've left @@ -310,64 +309,60 @@ export class CallViewModel extends ViewModel { }, ); - private readonly mediaItems: StateObservable = state( - combineLatest([ - this.remoteParticipants, - observeParticipantMedia(this.livekitRoom.localParticipant), - ]).pipe( - scan( - ( - prevItems, - [remoteParticipants, { participant: localParticipant }], - ) => { - let allGhosts = true; - - const newItems = new Map( - function* (this: CallViewModel): Iterable<[string, MediaItem]> { - for (const p of [localParticipant, ...remoteParticipants]) { - const member = findMatrixMember(this.matrixRoom, p.identity); - allGhosts &&= member === undefined; - // 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 - // "all ghosts" mode. - if (p.identity !== "" && member === undefined) { - logger.warn( - `Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, - ); - } - - const userMediaId = p.identity; + private readonly mediaItems: Observable = combineLatest([ + this.remoteParticipants, + observeParticipantMedia(this.livekitRoom.localParticipant), + ]).pipe( + scan( + (prevItems, [remoteParticipants, { participant: localParticipant }]) => { + let allGhosts = true; + + const newItems = new Map( + function* (this: CallViewModel): Iterable<[string, MediaItem]> { + for (const p of [localParticipant, ...remoteParticipants]) { + const member = findMatrixMember(this.matrixRoom, p.identity); + allGhosts &&= member === undefined; + // 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 + // "all ghosts" mode. + if (p.identity !== "" && member === undefined) { + logger.warn( + `Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, + ); + } + + const userMediaId = p.identity; + yield [ + userMediaId, + prevItems.get(userMediaId) ?? + new UserMedia(userMediaId, member, p, this.encrypted), + ]; + + if (p.isScreenShareEnabled) { + const screenShareId = `${userMediaId}:screen-share`; yield [ - userMediaId, - prevItems.get(userMediaId) ?? - new UserMedia(userMediaId, member, p, this.encrypted), + screenShareId, + prevItems.get(screenShareId) ?? + 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(); + 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 - return allGhosts ? new Map() : newItems; - }, - new Map(), - ), - map((ms) => [...ms.values()]), - finalizeValue((ts) => { - for (const t of ts) t.destroy(); - }), + // If every item is a ghost, that probably means we're still connecting + // and shouldn't bother showing anything yet + return allGhosts ? new Map() : newItems; + }, + new Map(), ), + map((ms) => [...ms.values()]), + finalizeValue((ts) => { + for (const t of ts) t.destroy(); + }), + shareReplay(1), ); private readonly userMedia: Observable = this.mediaItems.pipe( @@ -465,14 +460,15 @@ export class CallViewModel extends ViewModel { /** * The layout mode of the media tile grid. */ - public readonly gridMode = state(this._gridMode); + public readonly gridMode: Observable = this._gridMode; public setGridMode(value: GridMode): void { this._gridMode.next(value); } - public readonly layout: StateObservable = state( - combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => { + public readonly layout: Observable = combineLatest( + [this._gridMode, this.windowMode], + (gridMode, windowMode) => { switch (windowMode) { case "full screen": throw new Error("unimplemented"); @@ -501,110 +497,109 @@ export class CallViewModel extends ViewModel { } } } - }).pipe(switchAll()), - ); + }, + ).pipe(switchAll(), shareReplay(1)); /** * The media tiles to be displayed in the call view. */ // 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 - public readonly tiles: StateObservable[]> = - state( - combineLatest([ - this.remoteParticipants, - observeParticipantMedia(this.livekitRoom.localParticipant), - ]).pipe( - scan((ts, [remoteParticipants, { participant: localParticipant }]) => { - const ps = [localParticipant, ...remoteParticipants]; - const tilesById = new Map(ts.map((t) => [t.id, t])); - const now = Date.now(); - let allGhosts = true; - - const newTiles = ps.flatMap((p) => { - const userMediaId = p.identity; - const member = findMatrixMember(this.matrixRoom, userMediaId); - allGhosts &&= member === undefined; - const spokeRecently = - p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000; - - // 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 - // "all ghosts" mode. - if (userMediaId !== "" && member === undefined) { - logger.warn( - `Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`, - ); - } - - const userMediaVm = - tilesById.get(userMediaId)?.data ?? - (p instanceof LocalParticipant - ? new LocalUserMediaViewModel( - userMediaId, - member, - p, - this.encrypted, - ) - : new RemoteUserMediaViewModel( - userMediaId, - member, - p, - this.encrypted, - )); - tilesById.delete(userMediaId); - - const userMediaTile: TileDescriptor = { - id: userMediaId, - focused: false, - isPresenter: p.isScreenShareEnabled, - isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal, - hasVideo: p.isCameraEnabled, - local: p.isLocal, - largeBaseSize: false, - data: userMediaVm, - }; + public readonly tiles: Observable[]> = + combineLatest([ + this.remoteParticipants, + observeParticipantMedia(this.livekitRoom.localParticipant), + ]).pipe( + scan((ts, [remoteParticipants, { participant: localParticipant }]) => { + const ps = [localParticipant, ...remoteParticipants]; + const tilesById = new Map(ts.map((t) => [t.id, t])); + const now = Date.now(); + let allGhosts = true; + + const newTiles = ps.flatMap((p) => { + const userMediaId = p.identity; + const member = findMatrixMember(this.matrixRoom, userMediaId); + allGhosts &&= member === undefined; + const spokeRecently = + p.lastSpokeAt !== undefined && now - +p.lastSpokeAt <= 10000; + + // 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 + // "all ghosts" mode. + if (userMediaId !== "" && member === undefined) { + logger.warn( + `Ruh, roh! No matrix member found for SFU participant '${userMediaId}': creating g-g-g-ghost!`, + ); + } - if (p.isScreenShareEnabled) { - const screenShareId = `${userMediaId}:screen-share`; - const screenShareVm = - tilesById.get(screenShareId)?.data ?? - new ScreenShareViewModel( - screenShareId, + const userMediaVm = + tilesById.get(userMediaId)?.data ?? + (p instanceof LocalParticipant + ? new LocalUserMediaViewModel( + userMediaId, member, p, this.encrypted, - ); - tilesById.delete(screenShareId); - - const screenShareTile: TileDescriptor = { - id: screenShareId, - focused: true, - isPresenter: false, - isSpeaker: false, - 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[]), - finalizeValue((ts) => { - for (const t of ts) t.data.destroy(); - }), - ), + ) + : new RemoteUserMediaViewModel( + userMediaId, + member, + p, + this.encrypted, + )); + tilesById.delete(userMediaId); + + const userMediaTile: TileDescriptor = { + 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, + p, + this.encrypted, + ); + tilesById.delete(screenShareId); + + const screenShareTile: TileDescriptor = { + id: screenShareId, + focused: true, + isPresenter: false, + isSpeaker: false, + 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[]), + finalizeValue((ts) => { + for (const t of ts) t.data.destroy(); + }), + shareReplay(1), ); public constructor( diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index a35200803..0be40799c 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -21,7 +21,6 @@ import { observeParticipantEvents, observeParticipantMedia, } from "@livekit/components-core"; -import { StateObservable, state } from "@react-rxjs/core"; import { LocalParticipant, LocalTrack, @@ -35,12 +34,14 @@ import { import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix"; import { BehaviorSubject, + Observable, combineLatest, distinctUntilChanged, distinctUntilKeyChanged, fromEvent, map, of, + shareReplay, startWith, switchMap, } from "rxjs"; @@ -92,16 +93,15 @@ export function useNameData(vm: MediaViewModel): NameData { function observeTrackReference( participant: Participant, source: Track.Source, -): StateObservable { - return state( - observeParticipantMedia(participant).pipe( - map(() => ({ - participant, - publication: participant.getTrackPublication(source), - source, - })), - distinctUntilKeyChanged("publication"), - ), +): Observable { + return observeParticipantMedia(participant).pipe( + map(() => ({ + participant, + publication: participant.getTrackPublication(source), + source, + })), + distinctUntilKeyChanged("publication"), + shareReplay(1), ); } @@ -113,11 +113,11 @@ abstract class BaseMediaViewModel extends ViewModel { /** * The LiveKit video track for this media. */ - public readonly video: StateObservable; + public readonly video: Observable; /** * Whether there should be a warning that this media is unencrypted. */ - public readonly unencryptedWarning: StateObservable; + public readonly unencryptedWarning: Observable; public constructor( /** @@ -138,15 +138,13 @@ abstract class BaseMediaViewModel extends ViewModel { super(); const audio = observeTrackReference(participant, audioSource); this.video = observeTrackReference(participant, videoSource); - this.unencryptedWarning = state( - combineLatest( - [audio, this.video], - (a, v) => - callEncrypted && - (a.publication?.isEncrypted === false || - v.publication?.isEncrypted === false), - ).pipe(distinctUntilChanged()), - ); + this.unencryptedWarning = combineLatest( + [audio, this.video], + (a, v) => + callEncrypted && + (a.publication?.isEncrypted === false || + v.publication?.isEncrypted === false), + ).pipe(distinctUntilChanged(), shareReplay(1)); } } @@ -165,27 +163,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether the participant is speaking. */ - public readonly speaking = state( - observeParticipantEvents( - this.participant, - ParticipantEvent.IsSpeakingChanged, - ).pipe(map((p) => p.isSpeaking)), + public readonly speaking = observeParticipantEvents( + this.participant, + ParticipantEvent.IsSpeakingChanged, + ).pipe( + map((p) => p.isSpeaking), + shareReplay(1), ); /** * Whether this participant is sending audio (i.e. is unmuted on their side). */ - public readonly audioEnabled: StateObservable; + public readonly audioEnabled: Observable; /** * Whether this participant is sending video. */ - public readonly videoEnabled: StateObservable; + public readonly videoEnabled: Observable; private readonly _cropVideo = new BehaviorSubject(true); /** * 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 = this._cropVideo; public constructor( id: string, @@ -202,12 +201,12 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { Track.Source.Camera, ); - const media = observeParticipantMedia(participant); - this.audioEnabled = state( - media.pipe(map((m) => m.microphoneTrack?.isMuted === false)), + const media = observeParticipantMedia(participant).pipe(shareReplay(1)); + this.audioEnabled = media.pipe( + map((m) => m.microphoneTrack?.isMuted === false), ); - this.videoEnabled = state( - media.pipe(map((m) => m.cameraTrack?.isMuted === false)), + this.videoEnabled = media.pipe( + map((m) => m.cameraTrack?.isMuted === false), ); } @@ -223,19 +222,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether the video should be mirrored. */ - public readonly mirror = state( - this.video.pipe( - switchMap((v) => { - const track = v.publication?.track; - if (!(track instanceof LocalTrack)) return of(false); - // Watch for track restarts, because they indicate a camera switch - return fromEvent(track, TrackEvent.Restarted).pipe( - startWith(null), - // Mirror only front-facing cameras (those that face the user) - map(() => facingModeFromLocalTrack(track).facingMode === "user"), - ); - }), - ), + public readonly mirror = this.video.pipe( + switchMap((v) => { + const track = v.publication?.track; + if (!(track instanceof LocalTrack)) return of(false); + // Watch for track restarts, because they indicate a camera switch + return fromEvent(track, TrackEvent.Restarted).pipe( + startWith(null), + // Mirror only front-facing cameras (those that face the user) + map(() => facingModeFromLocalTrack(track).facingMode === "user"), + ); + }), + shareReplay(1), ); /** @@ -263,21 +261,21 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { /** * Whether we've disabled this participant's audio. */ - public readonly locallyMuted = state(this._locallyMuted); + public readonly locallyMuted: Observable = this._locallyMuted; private readonly _localVolume = new BehaviorSubject(1); /** * The volume to which we've set this participant's audio, as a scalar * multiplier. */ - public readonly localVolume = state(this._localVolume); + public readonly localVolume: Observable = this._localVolume; private readonly _pin = new BehaviorSubject(false); /** * Whether to pin this tile in a highly visible location near the start of the * grid. */ - public readonly pin = state(this._pin); + public readonly pin: Observable = this._pin; public constructor( id: string, diff --git a/src/state/subscribe.tsx b/src/state/subscribe.tsx deleted file mode 100644 index e0441aeb7..000000000 --- a/src/state/subscribe.tsx +++ /dev/null @@ -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( - render: ForwardRefRenderFunction, -): ForwardRefExoticComponent & RefAttributes> { - const Subscriber = forwardRef(({ p }, ref) => ( - {render(p, ref)} - )); - Subscriber.displayName = "Subscriber"; - - // eslint-disable-next-line react/display-name - const OuterComponent = forwardRef((p, ref) => ( - - - - )); - // Copy over the component's display name, default props, etc. - Object.assign(OuterComponent, render); - return OuterComponent; -} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index e4bb085de..77a3526f5 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -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 ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; import { animated } from "@react-spring/web"; -import { state, useStateObservable } from "@react-rxjs/core"; import { Observable, map, of } from "rxjs"; +import { useObservableEagerState } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; -import { subscribe } from "../state/subscribe"; import { LocalUserMediaViewModel, MediaViewModel, @@ -49,11 +48,11 @@ import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; // Screen share video is always enabled -const videoEnabledDefault = state(of(true)); +const videoEnabledDefault = of(true); // Never mirror screen share video -const mirrorDefault = state(of(false)); +const mirrorDefault = of(false); // Never crop screen share video -const cropVideoDefault = state(of(false)); +const cropVideoDefault = of(false); interface SpotlightItemProps { vm: MediaViewModel; @@ -66,28 +65,28 @@ interface SpotlightItemProps { snap: boolean; } -const SpotlightItem = subscribe( +const SpotlightItem = forwardRef( ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const { displayName, nameTag } = useNameData(vm); - const video = useStateObservable(vm.video); - const videoEnabled = useStateObservable( + const video = useObservableEagerState(vm.video); + const videoEnabled = useObservableEagerState( vm instanceof LocalUserMediaViewModel || vm instanceof RemoteUserMediaViewModel ? vm.videoEnabled : videoEnabledDefault, ); - const mirror = useStateObservable( + const mirror = useObservableEagerState( vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault, ); - const cropVideo = useStateObservable( + const cropVideo = useObservableEagerState( vm instanceof LocalUserMediaViewModel || vm instanceof RemoteUserMediaViewModel ? vm.cropVideo : cropVideoDefault, ); - const unencryptedWarning = useStateObservable(vm.unencryptedWarning); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); // Hook this item up to the intersection observer useEffect(() => { @@ -124,6 +123,8 @@ const SpotlightItem = subscribe( }, ); +SpotlightItem.displayName = "SpotlightItem"; + interface Props { vms: MediaViewModel[]; maximised: boolean; diff --git a/yarn.lock b/yarn.lock index 25643dbf3..61650f480 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2528,14 +2528,6 @@ resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80" integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg== -"@react-rxjs/core@^0.10.7": - version "0.10.7" - resolved "https://registry.yarnpkg.com/@react-rxjs/core/-/core-0.10.7.tgz#09951f43a6c80892526ac13d51859098b0e74993" - integrity sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A== - dependencies: - "@rx-state/core" "0.1.4" - use-sync-external-store "^1.0.0" - "@react-spring/animated@~9.7.3": version "9.7.3" resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f" @@ -2870,11 +2862,6 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.0.tgz#0bb7ac3cd1c3292db1f39afdabfd03ccea3a3d34" integrity sha512-aGg7iToJjdklmxlUlJh/PaPNa4PmqHfyRMLunbL3eaMO0gp656+q1zOKkpJ/CVe9CryJv6tAN1HDoR8cNGzkag== -"@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/feedback@7.105.0": version "7.105.0" resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-7.105.0.tgz#f2a25b55e5368509cfd540c21e74503568492057" @@ -8439,11 +8426,6 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" - integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== - usehooks-ts@2.16.0: version "2.16.0" resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.16.0.tgz#31deaa2f1147f65666aae925bd890b54e63b0d3f"