diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 29a4a4e47..64d24ebb2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -38,6 +38,15 @@ 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", + }, + ], }, settings: { react: { diff --git a/package.json b/package.json index 6d9074d29..6d2ac2df2 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@juggle/resize-observer": "^3.3.1", + "@livekit/components-core": "^0.7.0", "@livekit/components-react": "^1.1.0", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", "@opentelemetry/api": "^1.4.0", @@ -37,6 +38,7 @@ "@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", @@ -70,6 +72,7 @@ "react-router-dom": "^5.2.0", "react-use-clipboard": "^1.0.7", "react-use-measure": "^2.1.1", + "rxjs": "^7.8.1", "sdp-transform": "^2.14.1", "tinyqueue": "^2.0.3", "unique-names-generator": "^4.6.0", diff --git a/src/icons/Mic.svg b/src/icons/Mic.svg deleted file mode 100644 index 727ba05a8..000000000 --- a/src/icons/Mic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/icons/MicMuted.svg b/src/icons/MicMuted.svg deleted file mode 100644 index 0cb7ad1c9..000000000 --- a/src/icons/MicMuted.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/icons/MuteMic.svg b/src/icons/MuteMic.svg deleted file mode 100644 index 16841ab87..000000000 --- a/src/icons/MuteMic.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index 129ddf208..65a179296 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -26,7 +26,6 @@ import { } from "react"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { Observable } from "rxjs"; -import { logger } from "matrix-js-sdk/src/logger"; import { isFirefox, @@ -83,14 +82,7 @@ function useMediaDevice( // Tragically, the only way to get device names out of LiveKit is to specify a // kind, which then results in multiple permissions requests. const deviceObserver = useMemo( - () => - createMediaDeviceObserver( - kind, - () => { - logger.error("Error creating MediaDeviceObserver"); - }, - requestPermissions, - ), + () => createMediaDeviceObserver(kind, requestPermissions), [kind, requestPermissions], ); const available = useObservableState(deviceObserver, []); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index afdbc0445..a44b1bc6b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -19,14 +19,11 @@ import { RoomAudioRenderer, RoomContext, useLocalParticipant, - useParticipants, useTracks, } from "@livekit/components-react"; import { usePreventScroll } from "@react-aria/overlays"; import { ConnectionState, Room, Track } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room"; import { FC, ReactNode, @@ -39,9 +36,9 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import useMeasure from "react-use-measure"; -import { logger } from "matrix-js-sdk/src/logger"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import classNames from "classnames"; +import { useStateObservable } from "@react-rxjs/core"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -54,19 +51,14 @@ import { SettingsButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; -import { - useVideoGridLayout, - TileDescriptor, - VideoGrid, -} from "../video-grid/VideoGrid"; +import { useVideoGridLayout, VideoGrid } from "../video-grid/VideoGrid"; import { useShowConnectionStats } from "../settings/useSetting"; -import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { usePrefersReducedMotion } from "../usePrefersReducedMotion"; import { ElementWidgetActions, widget } from "../widget"; import styles from "./InCallView.module.css"; -import { ItemData, TileContent, VideoTile } from "../video-grid/VideoTile"; +import { VideoTile } from "../video-grid/VideoTile"; import { NewVideoGrid } from "../video-grid/NewVideoGrid"; import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership"; import { SettingsModal } from "../settings/SettingsModal"; @@ -81,18 +73,14 @@ import { MuteStates } from "./MuteStates"; import { MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; -import { - ECAddonConnectionState, - ECConnectionState, -} from "../livekit/useECConnectionState"; +import { ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; +import { useCallViewModel } from "../state/CallViewModel"; +import { subscribe } from "../state/subscribe"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); -// How long we wait after a focus switch before showing the real participant list again -const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; - export interface ActiveCallProps extends Omit { e2eeConfig: E2EEConfig; @@ -137,470 +125,333 @@ export interface InCallViewProps { onShareClick: (() => void) | null; } -export const InCallView: FC = ({ - client, - matrixInfo, - rtcSession, - livekitRoom, - muteStates, - participantCount, - onLeave, - hideHeader, - otelGroupCallMembership, - connState, - onShareClick, -}) => { - const { t } = useTranslation(); - usePreventScroll(); - useWakeLock(); +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({ polyfill: ResizeObserver }); + const boundsValid = bounds.height > 0; + // Merge the refs so they can attach to the same element + const containerRef = useMergedRefs(containerRef1, containerRef2); + + const screenSharingTracks = useTracks( + [{ source: Track.Source.ScreenShare, withPlaceholder: false }], + { + room: livekitRoom, + }, + ); + const { layout, setLayout } = useVideoGridLayout( + screenSharingTracks.length > 0, + ); - 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 [showConnectionStats] = useShowConnectionStats(); - const containerRef1 = useRef(null); - const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver }); - const boundsValid = bounds.height > 0; - // Merge the refs so they can attach to the same element - const containerRef = useMergedRefs(containerRef1, containerRef2); + const { hideScreensharing, showControls } = useUrlParams(); - const screenSharingTracks = useTracks( - [{ source: Track.Source.ScreenShare, withPlaceholder: false }], - { + const { isScreenShareEnabled, localParticipant } = useLocalParticipant({ room: livekitRoom, - }, - ); - const { layout, setLayout } = useVideoGridLayout( - screenSharingTracks.length > 0, - ); - - const [showConnectionStats] = useShowConnectionStats(); - - const { hideScreensharing, showControls } = useUrlParams(); - - const { isScreenShareEnabled, localParticipant } = useLocalParticipant({ - room: livekitRoom, - }); - - const toggleMicrophone = useCallback( - () => muteStates.audio.setEnabled?.((e) => !e), - [muteStates], - ); - const toggleCamera = useCallback( - () => muteStates.video.setEnabled?.((e) => !e), - [muteStates], - ); + }); - // This function incorrectly assumes that there is a camera and microphone, which is not always the case. - // TODO: Make sure that this module is resilient when it comes to camera/microphone availability! - useCallViewKeyboardShortcuts( - containerRef1, - toggleMicrophone, - toggleCamera, - (muted) => muteStates.audio.setEnabled?.(!muted), - ); + const toggleMicrophone = useCallback( + () => muteStates.audio.setEnabled?.((e) => !e), + [muteStates], + ); + const toggleCamera = useCallback( + () => muteStates.video.setEnabled?.((e) => !e), + [muteStates], + ); - useEffect(() => { - widget?.api.transport.send( - layout === "grid" - ? ElementWidgetActions.TileLayout - : ElementWidgetActions.SpotlightLayout, - {}, + // 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), ); - }, [layout]); - useEffect(() => { - if (widget) { - const onTileLayout = (ev: CustomEvent): void => { - setLayout("grid"); - widget!.api.transport.reply(ev.detail, {}); - }; - const onSpotlightLayout = (ev: CustomEvent): void => { - setLayout("spotlight"); - widget!.api.transport.reply(ev.detail, {}); - }; - - widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout); - widget.lazyActions.on( - ElementWidgetActions.SpotlightLayout, - onSpotlightLayout, + useEffect(() => { + widget?.api.transport.send( + layout === "grid" + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout, + {}, ); + }, [layout]); + + useEffect(() => { + if (widget) { + const onTileLayout = (ev: CustomEvent): void => { + setLayout("grid"); + widget!.api.transport.reply(ev.detail, {}); + }; + const onSpotlightLayout = ( + ev: CustomEvent, + ): void => { + setLayout("spotlight"); + widget!.api.transport.reply(ev.detail, {}); + }; - return () => { - widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout); - widget!.lazyActions.off( + widget.lazyActions.on(ElementWidgetActions.TileLayout, onTileLayout); + widget.lazyActions.on( ElementWidgetActions.SpotlightLayout, onSpotlightLayout, ); - }; - } - }, [setLayout]); - - const mobile = boundsValid && bounds.width <= 660; - const reducedControls = boundsValid && bounds.width <= 340; - const noControls = reducedControls && bounds.height <= 400; - - const items = useParticipantTiles(livekitRoom, rtcSession.room, connState); - const { fullscreenItem, toggleFullscreen, exitFullscreen } = - useFullscreen(items); - - // 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 Grid = - items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid; + return () => { + widget!.lazyActions.off( + ElementWidgetActions.TileLayout, + onTileLayout, + ); + widget!.lazyActions.off( + ElementWidgetActions.SpotlightLayout, + onSpotlightLayout, + ); + }; + } + }, [setLayout]); + + const mobile = boundsValid && bounds.width <= 660; + const reducedControls = boundsValid && bounds.width <= 340; + const noControls = reducedControls && bounds.height <= 400; + + const vm = useCallViewModel(rtcSession.room, livekitRoom, connState); + const items = useStateObservable(vm.tiles); + const { fullscreenItem, toggleFullscreen, exitFullscreen } = + useFullscreen(items); + + // The maximised participant: either the participant that the user has + // manually put in fullscreen, or the focused (active) participant if the + // window is too small to show everyone + const maximisedParticipant = useMemo( + () => + fullscreenItem ?? + (noControls + ? items.find((item) => item.isSpeaker) ?? items.at(0) ?? null + : null), + [fullscreenItem, noControls, items], + ); - const prefersReducedMotion = usePrefersReducedMotion(); + const Grid = + items.length > 12 && layout === "grid" ? NewVideoGrid : VideoGrid; - // This state is lifted out of NewVideoGrid so that layout states can be - // restored after a layout switch or upon exiting fullscreen - const layoutStates = useLayoutStates(); + const prefersReducedMotion = usePrefersReducedMotion(); - const renderContent = (): JSX.Element => { - if (items.length === 0) { - return ( -
-

{t("waiting_for_participants")}

-
- ); - } - if (maximisedParticipant) { - return ( - - ); - } + // This state is lifted out of NewVideoGrid so that layout states can be + // restored after a layout switch or upon exiting fullscreen + const layoutStates = useLayoutStates(); - return ( - - {(props): ReactNode => ( + const renderContent = (): JSX.Element => { + if (items.length === 0) { + return ( +
+

{t("waiting_for_participants")}

+
+ ); + } + if (maximisedParticipant) { + return ( 2} + targetHeight={bounds.height} + targetWidth={bounds.width} + key={maximisedParticipant.id} + showSpeakingIndicator={false} showConnectionStats={showConnectionStats} matrixInfo={matrixInfo} - {...props} - ref={props.ref as Ref} /> - )} -
- ); - }; - - const rageshakeRequestModalProps = useRageshakeRequestModal( - rtcSession.room.roomId, - ); - - const [settingsModalOpen, setSettingsModalOpen] = useState(false); - - const openSettings = useCallback( - () => setSettingsModalOpen(true), - [setSettingsModalOpen], - ); - const closeSettings = useCallback( - () => setSettingsModalOpen(false), - [setSettingsModalOpen], - ); - - const toggleScreensharing = useCallback(async () => { - exitFullscreen(); - await localParticipant.setScreenShareEnabled(!isScreenShareEnabled, { - audio: true, - selfBrowserSurface: "include", - surfaceSwitching: "include", - systemAudio: "include", - }); - }, [localParticipant, isScreenShareEnabled, exitFullscreen]); - - let footer: JSX.Element | null; - - if (noControls) { - footer = null; - } else { - const buttons: JSX.Element[] = []; - - buttons.push( - , - , - ); - - if (!reducedControls) { - if (canScreenshare && !hideScreensharing) { - buttons.push( - , ); } - buttons.push(); - } - - buttons.push( - , - ); - footer = ( -
- {!mobile && !hideHeader && ( -
- - -
- )} - {showControls &&
{buttons}
} - {!mobile && !hideHeader && showControls && ( - - )} -
- ); - } - return ( -
- {!hideHeader && maximisedParticipant === null && ( -
- - + {({ data: vm, ...props }): ReactNode => ( + 2} + showConnectionStats={showConnectionStats} + matrixInfo={matrixInfo} + {...props} + ref={props.ref as Ref} /> - - - {!reducedControls && showControls && onShareClick !== null && ( - - )} - -
- )} -
- - {renderContent()} - {footer} -
- {!noControls && } - -
- ); -}; + )} + + ); + }; -function findMatrixMember( - room: MatrixRoom, - id: string, -): RoomMember | undefined { - if (!id) return undefined; - - const parts = id.split(":"); - // must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon - if (parts.length < 3) { - logger.warn( - "Livekit participants ID doesn't look like a userId:deviceId combination", + const rageshakeRequestModalProps = useRageshakeRequestModal( + rtcSession.room.roomId, ); - return undefined; - } - parts.pop(); - const userId = parts.join(":"); - - return room.getMember(userId) ?? undefined; -} - -function useParticipantTiles( - livekitRoom: Room, - matrixRoom: MatrixRoom, - connState: ECConnectionState, -): TileDescriptor[] { - const previousTiles = useRef[]>([]); - - const sfuParticipants = useParticipants({ - room: livekitRoom, - }); - - const items = useMemo(() => { - let allGhosts = true; - - const speakActiveTime = new Date(); - speakActiveTime.setSeconds(speakActiveTime.getSeconds() - 10); - // Iterate over SFU participants (those who actually are present from the SFU perspective) and create tiles for them. - const tiles: TileDescriptor[] = sfuParticipants.flatMap( - (sfuParticipant) => { - const spokeRecently = - sfuParticipant.lastSpokeAt !== undefined && - sfuParticipant.lastSpokeAt > speakActiveTime; - - const id = sfuParticipant.identity; - const member = findMatrixMember(matrixRoom, id); - // We always start with a local participant wit the empty string as their ID before we're - // connected, this is fine and we'll be in "all ghosts" mode. - if (id !== "" && member === undefined) { - logger.warn( - `Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`, - ); - } - allGhosts &&= member === undefined; - - const userMediaTile = { - id, - focused: false, - isPresenter: sfuParticipant.isScreenShareEnabled, - isSpeaker: - (sfuParticipant.isSpeaking || spokeRecently) && - !sfuParticipant.isLocal, - hasVideo: sfuParticipant.isCameraEnabled, - local: sfuParticipant.isLocal, - largeBaseSize: false, - data: { - id, - member, - sfuParticipant, - content: TileContent.UserMedia, - }, - }; + const [settingsModalOpen, setSettingsModalOpen] = useState(false); - // If there is a screen sharing enabled for this participant, create a tile for it as well. - let screenShareTile: TileDescriptor | undefined; - if (sfuParticipant.isScreenShareEnabled) { - const screenShareId = `${id}:screen-share`; - screenShareTile = { - ...userMediaTile, - id: screenShareId, - focused: true, - largeBaseSize: true, - placeNear: id, - data: { - ...userMediaTile.data, - id: screenShareId, - content: TileContent.ScreenShare, - }, - }; - } - - return screenShareTile - ? [userMediaTile, screenShareTile] - : [userMediaTile]; - }, + const openSettings = useCallback( + () => setSettingsModalOpen(true), + [setSettingsModalOpen], ); - - PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged( - tiles.length, + const closeSettings = useCallback( + () => setSettingsModalOpen(false), + [setSettingsModalOpen], ); - // If every item is a ghost, that probably means we're still connecting and - // shouldn't bother showing anything yet - return allGhosts ? [] : tiles; - }, [matrixRoom, sfuParticipants]); - - // We carry over old tiles from the previous focus for some time after a focus switch - // so that the video tiles don't all disappear and reappear. - // This is set to true when the state transitions to Switching Focus and remains - // true for a short time after it changes (ie. connState is only switching focus for - // the time it takes us to reconnect to the conference). - // If there are still members that haven't reconnected after that time, they'll just - // appear to disconnect and will reappear once they reconnect. - const [isSwitchingFocus, setIsSwitchingFocus] = useState(false); - - useEffect(() => { - if (connState === ECAddonConnectionState.ECSwitchingFocus) { - setIsSwitchingFocus(true); - } else if (isSwitchingFocus) { - setTimeout(() => { - setIsSwitchingFocus(false); - }, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS); - } - }, [connState, setIsSwitchingFocus, isSwitchingFocus]); - - if ( - connState === ECAddonConnectionState.ECSwitchingFocus || - isSwitchingFocus - ) { - logger.debug("Switching focus: injecting previous tiles"); - - // inject the previous tile for members that haven't rejoined yet - const newItems = items.slice(0); - const rejoined = new Set(newItems.map((p) => p.id)); + 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( + , + , + ); - for (const prevTile of previousTiles.current) { - if (!rejoined.has(prevTile.id)) { - newItems.push(prevTile); + if (!reducedControls) { + if (canScreenshare && !hideScreensharing) { + buttons.push( + , + ); + } + buttons.push(); } + + buttons.push( + , + ); + footer = ( +
+ {!mobile && !hideHeader && ( +
+ + +
+ )} + {showControls &&
{buttons}
} + {!mobile && !hideHeader && showControls && ( + + )} +
+ ); } - return newItems; - } else { - previousTiles.current = items; - return items; - } -} + return ( +
+ {!hideHeader && maximisedParticipant === null && ( +
+ + + + + {!reducedControls && showControls && onShareClick !== null && ( + + )} + +
+ )} +
+ + {renderContent()} + {footer} +
+ {!noControls && ( + + )} + +
+ ); + }, +); diff --git a/src/room/useFullscreen.ts b/src/room/useFullscreen.ts index 38469808d..db769fa50 100644 --- a/src/room/useFullscreen.ts +++ b/src/room/useFullscreen.ts @@ -18,9 +18,9 @@ limitations under the License. import { logger } from "matrix-js-sdk/src/logger"; import { useCallback, useLayoutEffect, useRef } from "react"; -import { TileDescriptor } from "../video-grid/VideoGrid"; import { useReactiveState } from "../useReactiveState"; import { useEventTarget } from "../useEvents"; +import { TileDescriptor } from "../state/CallViewModel"; const isFullscreen = (): boolean => Boolean(document.fullscreenElement) || diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts new file mode 100644 index 000000000..4ffe429c0 --- /dev/null +++ b/src/state/CallViewModel.ts @@ -0,0 +1,267 @@ +/* +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 { + connectedParticipantsObserver, + observeParticipantMedia, +} from "@livekit/components-core"; +import { Room as LivekitRoom, RemoteParticipant } from "livekit-client"; +import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix"; +import { useEffect, useRef } from "react"; +import { + EMPTY, + Observable, + combineLatest, + concat, + mergeAll, + of, + sample, + scan, + startWith, + takeUntil, + zip, +} from "rxjs"; +import { state } from "@react-rxjs/core"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { ViewModel } from "./ViewModel"; +import { useObservable } from "./useObservable"; +import { + ECAddonConnectionState, + ECConnectionState, +} from "../livekit/useECConnectionState"; +import { usePrevious } from "../usePrevious"; +import { + TileViewModel, + UserMediaTileViewModel, + ScreenShareTileViewModel, +} from "./TileViewModel"; + +// Represents something that should get a tile on the layout, +// ie. a user's video feed or a screen share feed. +export interface TileDescriptor { + id: string; + focused: boolean; + isPresenter: boolean; + isSpeaker: boolean; + hasVideo: boolean; + local: boolean; + largeBaseSize: boolean; + placeNear?: string; + data: T; +} + +// How long we wait after a focus switch before showing the real participant +// list again +const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; + +function findMatrixMember( + room: MatrixRoom, + id: string, +): RoomMember | undefined { + if (!id) return undefined; + + const parts = id.split(":"); + // must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon + if (parts.length < 3) { + logger.warn( + "Livekit participants ID doesn't look like a userId:deviceId combination", + ); + return undefined; + } + + parts.pop(); + const userId = parts.join(":"); + + return room.getMember(userId) ?? undefined; +} + +// 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), + ); + + // Lists of participants to "hold" on display, even if LiveKit claims that + // they've left + private readonly remoteParticipantHolds = zip( + this.connectionState, + this.rawRemoteParticipants.pipe(sample(this.connectionState)), + (s, ps) => { + // Whenever we switch focuses, we should retain all the previous + // participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to + // give their clients time to switch over and avoid jarring layout shifts + if (s === ECAddonConnectionState.ECSwitchingFocus) { + return concat( + // Hold these participants + of({ hold: ps }), + // Wait for time to pass and the connection state to have changed + Promise.all([ + new Promise((resolve) => + setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS), + ), + new Promise((resolve) => { + const subscription = this.connectionState + .pipe(takeUntil(this.destroyed)) + .subscribe((s) => { + if (s !== ECAddonConnectionState.ECSwitchingFocus) { + resolve(); + subscription.unsubscribe(); + } + }); + }), + // Then unhold them + ]).then(() => Promise.resolve({ unhold: ps })), + ); + } else { + return EMPTY; + } + }, + ).pipe( + mergeAll(), + // Aggregate the hold instructions into a single list showing which + // participants are being held + scan( + (holds, instruction) => + "hold" in instruction + ? [instruction.hold, ...holds] + : holds.filter((h) => h !== instruction.unhold), + [] as RemoteParticipant[][], + ), + startWith([]), + ); + + private readonly remoteParticipants = combineLatest( + [this.rawRemoteParticipants, this.remoteParticipantHolds], + (raw, holds) => { + const result = [...raw]; + const resultIds = new Set(result.map((p) => p.identity)); + + // Incorporate the held participants into the list + for (const hold of holds) { + for (const p of hold) { + if (!resultIds.has(p.identity)) { + result.push(p); + resultIds.add(p.identity); + } + } + } + + return result; + }, + ); + + public readonly tiles = 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 id = p.identity; + const member = findMatrixMember(this.matrixRoom, id); + 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 (id !== "" && member === undefined) { + logger.warn( + `Ruh, roh! No matrix member found for SFU participant '${id}': creating g-g-g-ghost!`, + ); + } + + const userMediaTile: TileDescriptor = { + id, + focused: false, + isPresenter: p.isScreenShareEnabled, + isSpeaker: (p.isSpeaking || spokeRecently) && !p.isLocal, + hasVideo: p.isCameraEnabled, + local: p.isLocal, + largeBaseSize: false, + data: + tilesById.get(id)?.data ?? + new UserMediaTileViewModel(id, member, p), + }; + + if (p.isScreenShareEnabled) { + const screenShareId = `${id}:screen-share`; + const screenShareTile: TileDescriptor = { + id: screenShareId, + focused: true, + isPresenter: false, + isSpeaker: false, + hasVideo: true, + local: p.isLocal, + largeBaseSize: true, + placeNear: id, + data: + tilesById.get(id)?.data ?? + new ScreenShareTileViewModel(id, member, p), + }; + return [userMediaTile, screenShareTile]; + } else { + return [userMediaTile]; + } + }); + + // 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[]), + ), + ); + + public constructor( + // A call is permanently tied to a single Matrix room and LiveKit room + private readonly matrixRoom: MatrixRoom, + private readonly livekitRoom: LivekitRoom, + private readonly connectionState: Observable, + ) { + super(); + } +} + +export function useCallViewModel( + matrixRoom: MatrixRoom, + livekitRoom: LivekitRoom, + connectionState: ECConnectionState, +): CallViewModel { + const prevMatrixRoom = usePrevious(matrixRoom); + const prevLivekitRoom = usePrevious(livekitRoom); + const connectionStateObservable = useObservable(connectionState); + + const vm = useRef(); + if (matrixRoom !== prevMatrixRoom || livekitRoom !== prevLivekitRoom) { + vm.current?.destroy(); + vm.current = new CallViewModel( + matrixRoom, + livekitRoom, + connectionStateObservable, + ); + } + + useEffect(() => vm.current?.destroy(), []); + + return vm.current!; +} diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts new file mode 100644 index 000000000..5205a3003 --- /dev/null +++ b/src/state/TileViewModel.ts @@ -0,0 +1,53 @@ +/* +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 { LocalParticipant, RemoteParticipant } from "livekit-client"; +import { RoomMember } from "matrix-js-sdk/src/matrix"; + +export abstract class TileViewModel { + // TODO: Properly separate the data layer from the UI layer by keeping the + // member and LiveKit participant objects internal. The only LiveKit-specific + // thing we need to expose here is a TrackReference for the video, everything + // else should be simple strings, flags, and callbacks. + public abstract readonly id: string; + public abstract readonly member: RoomMember | undefined; + public abstract readonly sfuParticipant: LocalParticipant | RemoteParticipant; +} + +// Right now it looks kind of pointless to have user media and screen share be +// represented by two classes rather than a single flag, but this will come in +// handy when we go to move more business logic out of VideoTile and into this +// file + +export class UserMediaTileViewModel extends TileViewModel { + public constructor( + public readonly id: string, + public readonly member: RoomMember | undefined, + public readonly sfuParticipant: LocalParticipant | RemoteParticipant, + ) { + super(); + } +} + +export class ScreenShareTileViewModel extends TileViewModel { + public constructor( + public readonly id: string, + public readonly member: RoomMember | undefined, + public readonly sfuParticipant: LocalParticipant | RemoteParticipant, + ) { + super(); + } +} diff --git a/src/state/ViewModel.ts b/src/state/ViewModel.ts new file mode 100644 index 000000000..d10afad1b --- /dev/null +++ b/src/state/ViewModel.ts @@ -0,0 +1,33 @@ +/* +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 { Subject } from "rxjs"; + +/** + * An MVVM view model. + */ +export abstract class ViewModel { + protected readonly destroyed = new Subject(); + + /** + * Instructs the ViewModel to clean up its resources. If you forget to call + * this, there may be memory leaks! + */ + public destroy(): void { + this.destroyed.next(); + this.destroyed.complete(); + } +} diff --git a/src/state/subscribe.tsx b/src/state/subscribe.tsx new file mode 100644 index 000000000..461a178c2 --- /dev/null +++ b/src/state/subscribe.tsx @@ -0,0 +1,38 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { FC } from "react"; +// eslint-disable-next-line no-restricted-imports +import { Subscribe, RemoveSubscribe } from "@react-rxjs/core"; + +/** + * Wraps a React component that consumes Observables, resulting in a component + * that safely subscribes to its Observables before rendering. The component + * will return null until the subscriptions are created. + */ +export function subscribe

(render: FC

): FC

{ + const InnerComponent: FC<{ p: P }> = ({ p }) => ( + {render(p)} + ); + const OuterComponent: FC

= (p) => ( + + + + ); + // Copy over the component's display name, default props, etc. + Object.assign(OuterComponent, render); + return OuterComponent; +} diff --git a/src/state/useObservable.ts b/src/state/useObservable.ts new file mode 100644 index 000000000..a55a6e408 --- /dev/null +++ b/src/state/useObservable.ts @@ -0,0 +1,31 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useEffect, useRef } from "react"; +import { BehaviorSubject, Observable } from "rxjs"; + +/** + * React hook that creates an Observable from a changing value. The Observable + * replays its current value upon subscription, emits whenever the value + * changes, and completes when the component is unmounted. + */ +export function useObservable(value: T): Observable { + const subject = useRef>(); + subject.current ??= new BehaviorSubject(value); + if (value !== subject.current.value) subject.current.next(value); + useEffect(() => subject.current!.complete(), []); + return subject.current; +} diff --git a/src/usePrevious.ts b/src/usePrevious.ts new file mode 100644 index 000000000..b298c10ea --- /dev/null +++ b/src/usePrevious.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { useRef } from "react"; + +/** + * React hook that returns the value given on the previous render. + */ +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + const previous = ref.current; + ref.current = value; + return previous; +} diff --git a/src/video-grid/BigGrid.tsx b/src/video-grid/BigGrid.tsx index 39b6d82d7..275523fef 100644 --- a/src/video-grid/BigGrid.tsx +++ b/src/video-grid/BigGrid.tsx @@ -19,11 +19,11 @@ import { RectReadOnly } from "react-use-measure"; import { FC, memo, ReactNode } from "react"; import { zip } from "lodash"; -import { TileDescriptor } from "./VideoGrid"; import { Slot } from "./NewVideoGrid"; import { Layout } from "./Layout"; import { count, findLastIndex } from "../array-utils"; import styles from "./BigGrid.module.css"; +import { TileDescriptor } from "../state/CallViewModel"; /** * A 1×1 cell in a grid which belongs to a tile. diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx index fe8f2ec70..b540cbe17 100644 --- a/src/video-grid/Layout.tsx +++ b/src/video-grid/Layout.tsx @@ -18,7 +18,7 @@ import { ComponentType, ReactNode, useCallback, useMemo, useRef } from "react"; import type { RectReadOnly } from "react-use-measure"; import { useReactiveState } from "../useReactiveState"; -import type { TileDescriptor } from "./VideoGrid"; +import { TileDescriptor } from "../state/CallViewModel"; /** * A video grid layout system with concrete states of type State. diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 8b32045c5..6de5f58dd 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -32,7 +32,6 @@ import styles from "./NewVideoGrid.module.css"; import { VideoGridProps as Props, TileSpring, - TileDescriptor, ChildrenProperties, } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; @@ -40,6 +39,7 @@ import { useMergedRefs } from "../useMergedRefs"; import { TileWrapper } from "./TileWrapper"; import { BigGrid } from "./BigGrid"; import { useLayout } from "./Layout"; +import { TileDescriptor } from "../state/CallViewModel"; interface Rect { x: number; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index d918cd26e..73e884e41 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -44,6 +44,7 @@ import styles from "./VideoGrid.module.css"; import { Layout } from "../room/LayoutToggle"; import { TileWrapper } from "./TileWrapper"; import { LayoutStatesMap } from "./Layout"; +import { TileDescriptor } from "../state/CallViewModel"; interface TilePosition { x: number; @@ -838,20 +839,6 @@ export interface VideoGridProps { children: (props: ChildrenProperties) => ReactNode; } -// Represents something that should get a tile on the layout, -// ie. a user's video feed or a screen share feed. -export interface TileDescriptor { - id: string; - focused: boolean; - isPresenter: boolean; - isSpeaker: boolean; - hasVideo: boolean; - local: boolean; - largeBaseSize: boolean; - placeNear?: string; - data: T; -} - export function VideoGrid({ items, layout, diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index 7a785a353..1bb67b226 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -45,6 +45,11 @@ import { useReactiveState } from "../useReactiveState"; import { AudioButton, FullscreenButton } from "../button/Button"; import { VideoTileSettingsModal } from "./VideoTileSettingsModal"; import { MatrixInfo } from "../room/VideoPreview"; +import { + ScreenShareTileViewModel, + TileViewModel, + UserMediaTileViewModel, +} from "../state/TileViewModel"; export interface ItemData { id: string; @@ -59,7 +64,7 @@ export enum TileContent { } interface Props { - data: ItemData; + vm: TileViewModel; maximised: boolean; fullscreen: boolean; onToggleFullscreen: (itemId: string) => void; @@ -78,7 +83,7 @@ interface Props { export const VideoTile = forwardRef( ( { - data, + vm, maximised, fullscreen, onToggleFullscreen, @@ -94,7 +99,7 @@ export const VideoTile = forwardRef( ) => { const { t } = useTranslation(); - const { content, sfuParticipant, member } = data; + const { id, sfuParticipant, member } = vm; // Handle display name changes. const [displayName, setDisplayName] = useReactiveState( @@ -115,13 +120,13 @@ export const VideoTile = forwardRef( }, [member, setDisplayName]); const audioInfo = useMediaTrack( - content === TileContent.UserMedia + vm instanceof UserMediaTileViewModel ? Track.Source.Microphone : Track.Source.ScreenShareAudio, sfuParticipant, ); const videoInfo = useMediaTrack( - content === TileContent.UserMedia + vm instanceof UserMediaTileViewModel ? Track.Source.Camera : Track.Source.ScreenShare, sfuParticipant, @@ -134,8 +139,8 @@ export const VideoTile = forwardRef( const MicIcon = muted ? MicOffSolidIcon : MicOnSolidIcon; const onFullscreen = useCallback(() => { - onToggleFullscreen(data.id); - }, [data, onToggleFullscreen]); + onToggleFullscreen(id); + }, [id, onToggleFullscreen]); const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] = useState(false); @@ -159,7 +164,7 @@ export const VideoTile = forwardRef( />, ); - if (content === TileContent.ScreenShare) { + if (vm instanceof ScreenShareTileViewModel) { toolbarButtons.push( ( [styles.isLocal]: sfuParticipant.isLocal, [styles.speaking]: sfuParticipant.isSpeaking && - content === TileContent.UserMedia && + vm instanceof UserMediaTileViewModel && showSpeakingIndicator, - [styles.screenshare]: content === TileContent.ScreenShare, + [styles.screenshare]: vm instanceof ScreenShareTileViewModel, [styles.maximised]: maximised, })} style={style} @@ -189,7 +194,7 @@ export const VideoTile = forwardRef( {toolbarButtons.length > 0 && (!maximised || fullscreen) && (

{toolbarButtons}
)} - {content === TileContent.UserMedia && + {vm instanceof UserMediaTileViewModel && !sfuParticipant.isCameraEnabled && ( <>
@@ -203,7 +208,7 @@ export const VideoTile = forwardRef( /> )} - {content === TileContent.ScreenShare ? ( + {vm instanceof ScreenShareTileViewModel ? (
{t("video_tile.presenter_label", { displayName })}
@@ -245,7 +250,7 @@ export const VideoTile = forwardRef( ( // eslint-disable-next-line react/no-unknown-property disablepictureinpicture="true" /> - {!maximised && ( + {!maximised && sfuParticipant instanceof RemoteParticipant && ( diff --git a/src/video-grid/VideoTileSettingsModal.tsx b/src/video-grid/VideoTileSettingsModal.tsx index b8e464c94..ad9bcad71 100644 --- a/src/video-grid/VideoTileSettingsModal.tsx +++ b/src/video-grid/VideoTileSettingsModal.tsx @@ -22,19 +22,18 @@ import { FieldRow } from "../input/Input"; import { Modal } from "../Modal"; import styles from "./VideoTileSettingsModal.module.css"; import { VolumeIcon } from "../button/VolumeIcon"; -import { ItemData, TileContent } from "./VideoTile"; interface LocalVolumeProps { participant: RemoteParticipant; - content: TileContent; + media: "user media" | "screen share"; } const LocalVolume: FC = ({ participant, - content, + media, }: LocalVolumeProps) => { const source = - content === TileContent.UserMedia + media === "user media" ? Track.Source.Microphone : Track.Source.ScreenShareAudio; @@ -67,13 +66,15 @@ const LocalVolume: FC = ({ }; interface Props { - data: ItemData; + participant: RemoteParticipant; + media: "user media" | "screen share"; open: boolean; onDismiss: () => void; } export const VideoTileSettingsModal: FC = ({ - data, + participant, + media, open, onDismiss, }) => { @@ -87,10 +88,7 @@ export const VideoTileSettingsModal: FC = ({ onDismiss={onDismiss} >
- +
); diff --git a/test/video-grid/BigGrid-test.ts b/test/video-grid/BigGrid-test.ts index 06e73dac7..3d29db6cc 100644 --- a/test/video-grid/BigGrid-test.ts +++ b/test/video-grid/BigGrid-test.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { TileDescriptor } from "../../src/state/CallViewModel"; import { addItems, column, @@ -26,7 +27,6 @@ import { row, moveTile, } from "../../src/video-grid/BigGrid"; -import { TileDescriptor } from "../../src/video-grid/VideoGrid"; /** * Builds a grid from a string specifying the contents of each cell as a letter. @@ -315,7 +315,7 @@ jk`; const grid = mkGrid(input); let gridAfter = grid; - const toggle = (tileId: string) => { + const toggle = (tileId: string): void => { const tile = grid.cells.find((c) => c?.item.id === tileId)!.item; gridAfter = cycleTileSize(gridAfter, tile); }; diff --git a/test/video-grid/VideoGrid-test.ts b/test/video-grid/VideoGrid-test.ts index 46134a13e..cf15c0221 100644 --- a/test/video-grid/VideoGrid-test.ts +++ b/test/video-grid/VideoGrid-test.ts @@ -14,11 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - Tile, - TileDescriptor, - reorderTiles, -} from "../../src/video-grid/VideoGrid"; +import { TileDescriptor } from "../../src/state/CallViewModel"; +import { Tile, reorderTiles } from "../../src/video-grid/VideoGrid"; const alice: Tile = { key: "alice", diff --git a/yarn.lock b/yarn.lock index 2938fe267..2ec5393d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2050,6 +2050,17 @@ loglevel "^1.8.1" rxjs "^7.8.0" +"@livekit/components-core@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.7.0.tgz#1283a34753c8f1bb805b987684a3e29d1bc2eb39" + integrity sha512-KwzqnW8V9G+4fXc4gOxpXqQFLpL/kFBn82sYO10zHjHfNKSw4pRj1sDLTWc5UF22W2Z461/bqMtB+3WGB/3Dtg== + dependencies: + "@floating-ui/dom" "^1.1.0" + email-regex "^5.0.0" + global-tld-list "^0.0.1139" + loglevel "^1.8.1" + rxjs "^7.8.0" + "@livekit/components-react@^1.1.0": version "1.4.2" resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-1.4.2.tgz#f4b09621a244fa832803ca0fc4b200a548dc3b8d" @@ -2726,6 +2737,14 @@ resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80" integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg== +"@react-rxjs/core@^0.10.7": + version "0.10.7" + resolved "https://registry.yarnpkg.com/@react-rxjs/core/-/core-0.10.7.tgz#09951f43a6c80892526ac13d51859098b0e74993" + integrity sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A== + dependencies: + "@rx-state/core" "0.1.4" + use-sync-external-store "^1.0.0" + "@react-spring/animated@~9.7.3": version "9.7.3" resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f" @@ -3045,6 +3064,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.6.1.tgz#45785b5caf83200a34a9867ba50d69560880c120" integrity sha512-0zfTlFAIhgz8V2G8STq8toAjsYYA6eci1hnXuyOTUFnymrtJwnS6uGKiv3v5UrPZkBlamLvrLV2iiaeqCKzb0A== +"@rx-state/core@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@rx-state/core/-/core-0.1.4.tgz#586dde80be9dbdac31844006a0dcaa2bc7f35a5c" + integrity sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ== + "@sentry-internal/tracing@7.83.0": version "7.83.0" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.83.0.tgz#8f69d339569b020c495f8350a8ea527c369586e8" @@ -5848,6 +5872,11 @@ glob@^7.0.0, glob@^7.1.3, glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +global-tld-list@^0.0.1139: + version "0.0.1139" + resolved "https://registry.yarnpkg.com/global-tld-list/-/global-tld-list-0.0.1139.tgz#70400a3f3ccac1a19a8184274a1b117bc8a27969" + integrity sha512-TCWjAwHPzFV6zbQ5jnJvJTctesHGJr9BppxivRuIxTiIFUzaxy1F0674cxjoJecW5s8V32Q5i35dBFqvAy7eGQ== + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -8579,7 +8608,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.5.2, rxjs@^7.8.0: +rxjs@^7.5.2, rxjs@^7.8.0, rxjs@^7.8.1: version "7.8.1" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== @@ -9385,6 +9414,11 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sync-external-store@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + usehooks-ts@^2.9.1: version "2.9.1" resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.9.1.tgz#953d3284851ffd097432379e271ce046a8180b37"