Replace react-rxjs with observable-hooks

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 <Subscribe /> and <RemoveSubscribe /> 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!
This commit is contained in:
Robin
2024-05-16 15:23:10 -04:00
parent 0d485ef97f
commit af0bd795b5
9 changed files with 627 additions and 723 deletions

View File

@@ -38,15 +38,6 @@ module.exports = {
"jsx-a11y/media-has-caption": "off", "jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly. // We should use the js-sdk logger, never console directly.
"no-console": ["error"], "no-console": ["error"],
"no-restricted-imports": [
"error",
{
name: "@react-rxjs/core",
importNames: ["Subscribe", "RemoveSubscribe"],
message:
"These components are easy to misuse, please use the 'subscribe' component wrapper instead",
},
],
"react/display-name": "error", "react/display-name": "error",
}, },
settings: { settings: {

View File

@@ -41,7 +41,6 @@
"@react-aria/tabs": "^3.1.0", "@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3", "@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0", "@react-aria/utils": "^3.10.0",
"@react-rxjs/core": "^0.10.7",
"@react-spring/web": "^9.4.4", "@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4", "@react-stately/collections": "^3.3.4",
"@react-stately/select": "^3.1.3", "@react-stately/select": "^3.1.3",

View File

@@ -14,16 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { CSSProperties, useMemo } from "react"; import { CSSProperties, forwardRef, useMemo } from "react";
import { StateObservable, state, useStateObservable } from "@react-rxjs/core"; import { BehaviorSubject, Observable, distinctUntilChanged } from "rxjs";
import { BehaviorSubject, distinctUntilChanged } from "rxjs"; import { useObservableEagerState } from "observable-hooks";
import { GridLayout as GridLayoutModel } from "../state/CallViewModel"; import { GridLayout as GridLayoutModel } from "../state/CallViewModel";
import { MediaViewModel } from "../state/MediaViewModel"; import { MediaViewModel } from "../state/MediaViewModel";
import { LayoutSystem, Slot } from "./Grid"; import { LayoutSystem, Slot } from "./Grid";
import styles from "./GridLayout.module.css"; import styles from "./GridLayout.module.css";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { subscribe } from "../state/subscribe";
import { Alignment } from "../room/InCallView"; import { Alignment } from "../room/InCallView";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
@@ -48,7 +47,7 @@ const slotMaxAspectRatio = 17 / 9;
const slotMinAspectRatio = 4 / 3; const slotMinAspectRatio = 4 / 3;
export const gridLayoutSystems = ( export const gridLayoutSystems = (
minBounds: StateObservable<Bounds>, minBounds: Observable<Bounds>,
floatingAlignment: BehaviorSubject<Alignment>, floatingAlignment: BehaviorSubject<Alignment>,
): GridLayoutSystems => ({ ): GridLayoutSystems => ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
@@ -58,18 +57,16 @@ export const gridLayoutSystems = (
new Map( new Map(
model.spotlight === undefined ? [] : [["spotlight", model.spotlight]], model.spotlight === undefined ? [] : [["spotlight", model.spotlight]],
), ),
Layout: subscribe(function GridLayoutFixed({ model }, ref) { Layout: forwardRef(function GridLayoutFixed({ model }, ref) {
const { width, height } = useStateObservable(minBounds); const { width, height } = useObservableEagerState(minBounds);
const alignment = useStateObservable( const alignment = useObservableEagerState(
useInitial<StateObservable<Alignment>>(() => useInitial(() =>
state(
floatingAlignment.pipe( floatingAlignment.pipe(
distinctUntilChanged( distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline, (a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
), ),
), ),
), ),
),
); );
const [generation] = useReactiveState<number>( const [generation] = useReactiveState<number>(
(prev) => (prev === undefined ? 0 : prev + 1), (prev) => (prev === undefined ? 0 : prev + 1),
@@ -106,8 +103,8 @@ export const gridLayoutSystems = (
// The scrolling part of the layout is where all the grid tiles live // The scrolling part of the layout is where all the grid tiles live
scrolling: { scrolling: {
tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])), tiles: (model) => new Map(model.grid.map((tile) => [tile.id, tile])),
Layout: subscribe(function GridLayout({ model }, ref) { Layout: forwardRef(function GridLayout({ model }, ref) {
const { width, height: minHeight } = useStateObservable(minBounds); const { width, height: minHeight } = useObservableEagerState(minBounds);
// The goal here is to determine the grid size and padding that maximizes // The goal here is to determine the grid size and padding that maximizes
// use of screen space for n tiles without making those tiles too small or // use of screen space for n tiles without making those tiles too small or

View File

@@ -35,8 +35,8 @@ import {
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames"; import classNames from "classnames";
import { state, useStateObservable } from "@react-rxjs/core";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
@@ -76,7 +76,6 @@ import {
TileDescriptor, TileDescriptor,
useCallViewModel, useCallViewModel,
} from "../state/CallViewModel"; } from "../state/CallViewModel";
import { subscribe } from "../state/subscribe";
import { Grid, TileProps } from "../grid/Grid"; import { Grid, TileProps } from "../grid/Grid";
import { MediaViewModel } from "../state/MediaViewModel"; import { MediaViewModel } from "../state/MediaViewModel";
import { gridLayoutSystems } from "../grid/GridLayout"; import { gridLayoutSystems } from "../grid/GridLayout";
@@ -143,8 +142,7 @@ export interface InCallViewProps {
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
} }
export const InCallView: FC<InCallViewProps> = subscribe( export const InCallView: FC<InCallViewProps> = ({
({
client, client,
matrixInfo, matrixInfo,
rtcSession, rtcSession,
@@ -156,7 +154,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
otelGroupCallMembership, otelGroupCallMembership,
connState, connState,
onShareClick, onShareClick,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
usePreventScroll(); usePreventScroll();
useWakeLock(); useWakeLock();
@@ -223,9 +221,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
setLegacyLayout("grid"); setLegacyLayout("grid");
widget!.api.transport.reply(ev.detail, {}); widget!.api.transport.reply(ev.detail, {});
}; };
const onSpotlightLayout = ( const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev: CustomEvent<IWidgetApiRequest>,
): void => {
setLegacyLayout("spotlight"); setLegacyLayout("spotlight");
widget!.api.transport.reply(ev.detail, {}); widget!.api.transport.reply(ev.detail, {});
}; };
@@ -237,10 +233,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
); );
return (): void => { return (): void => {
widget!.lazyActions.off( widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
ElementWidgetActions.TileLayout,
onTileLayout,
);
widget!.lazyActions.off( widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout, ElementWidgetActions.SpotlightLayout,
onSpotlightLayout, onSpotlightLayout,
@@ -259,8 +252,8 @@ export const InCallView: FC<InCallViewProps> = subscribe(
matrixInfo.e2eeSystem.kind !== E2eeType.NONE, matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState, connState,
); );
const items = useStateObservable(vm.tiles); const items = useObservableEagerState(vm.tiles);
const layout = useStateObservable(vm.layout); const layout = useObservableEagerState(vm.layout);
const hasSpotlight = layout.spotlight !== undefined; const hasSpotlight = layout.spotlight !== undefined;
// Hack: We insert a dummy "spotlight" tile into the tiles we pass to // Hack: We insert a dummy "spotlight" tile into the tiles we pass to
// useFullscreen so that we can control the fullscreen state of the // useFullscreen so that we can control the fullscreen state of the
@@ -326,7 +319,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
() => new BehaviorSubject(defaultAlignment), () => new BehaviorSubject(defaultAlignment),
); );
const { fixed, scrolling } = useInitial(() => const { fixed, scrolling } = useInitial(() =>
gridLayoutSystems(state(gridBoundsObservable), floatingAlignment), gridLayoutSystems(gridBoundsObservable, floatingAlignment),
); );
const setGridMode = useCallback( const setGridMode = useCallback(
@@ -583,9 +576,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
<RoomAudioRenderer /> <RoomAudioRenderer />
{renderContent()} {renderContent()}
{footer} {footer}
{!noControls && ( {!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<RageshakeRequestModal {...rageshakeRequestModalProps} />
)}
<SettingsModal <SettingsModal
client={client} client={client}
roomId={rtcSession.room.roomId} roomId={rtcSession.room.roomId}
@@ -596,5 +587,4 @@ export const InCallView: FC<InCallViewProps> = subscribe(
/> />
</div> </div>
); );
}, };
);

View File

@@ -50,7 +50,6 @@ import {
timer, timer,
zip, zip,
} from "rxjs"; } from "rxjs";
import { StateObservable, state } from "@react-rxjs/core";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
@@ -158,7 +157,7 @@ class UserMedia {
? new LocalUserMediaViewModel(id, member, participant, callEncrypted) ? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted); : new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
this.speaker = this.vm.speaking.pipeState( this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of // Require 1 s of continuous speaking to become a speaker, and 60 s of
// continuous silence to stop being considered a speaker // continuous silence to stop being considered a speaker
audit((s) => audit((s) =>
@@ -234,9 +233,9 @@ function findMatrixMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here // TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
private readonly rawRemoteParticipants = state( private readonly rawRemoteParticipants = connectedParticipantsObserver(
connectedParticipantsObserver(this.livekitRoom), this.livekitRoom,
); ).pipe(shareReplay(1));
// Lists of participants to "hold" on display, even if LiveKit claims that // Lists of participants to "hold" on display, even if LiveKit claims that
// they've left // they've left
@@ -309,16 +308,12 @@ export class CallViewModel extends ViewModel {
}, },
); );
private readonly mediaItems: StateObservable<MediaItem[]> = state( private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
]).pipe( ]).pipe(
scan( scan(
( (prevItems, [remoteParticipants, { participant: localParticipant }]) => {
prevItems,
[remoteParticipants, { participant: localParticipant }],
) => {
let allGhosts = true; let allGhosts = true;
const newItems = new Map( const newItems = new Map(
@@ -366,7 +361,7 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
), shareReplay(1),
); );
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe( private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
@@ -462,14 +457,15 @@ export class CallViewModel extends ViewModel {
/** /**
* The layout mode of the media tile grid. * The layout mode of the media tile grid.
*/ */
public readonly gridMode = state(this._gridMode); public readonly gridMode: Observable<GridMode> = this._gridMode;
public setGridMode(value: GridMode): void { public setGridMode(value: GridMode): void {
this._gridMode.next(value); this._gridMode.next(value);
} }
public readonly layout: StateObservable<Layout> = state( public readonly layout: Observable<Layout> = combineLatest(
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => { [this._gridMode, this.windowMode],
(gridMode, windowMode) => {
switch (windowMode) { switch (windowMode) {
case "full screen": case "full screen":
throw new Error("unimplemented"); throw new Error("unimplemented");
@@ -498,16 +494,15 @@ export class CallViewModel extends ViewModel {
} }
} }
} }
}).pipe(switchAll()), },
); ).pipe(switchAll(), shareReplay(1));
/** /**
* The media tiles to be displayed in the call view. * The media tiles to be displayed in the call view.
*/ */
// TODO: Get rid of this field, replacing it with the 'layout' field above // TODO: Get rid of this field, replacing it with the 'layout' field above
// which keeps more details of the layout order internal to the view model // which keeps more details of the layout order internal to the view model
public readonly tiles: StateObservable<TileDescriptor<MediaViewModel>[]> = public readonly tiles: Observable<TileDescriptor<MediaViewModel>[]> =
state(
combineLatest([ combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
@@ -601,7 +596,7 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.data.destroy(); for (const t of ts) t.data.destroy();
}), }),
), shareReplay(1),
); );
public constructor( public constructor(

View File

@@ -21,7 +21,6 @@ import {
observeParticipantEvents, observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
} from "@livekit/components-core"; } from "@livekit/components-core";
import { StateObservable, state } from "@react-rxjs/core";
import { import {
LocalParticipant, LocalParticipant,
LocalTrack, LocalTrack,
@@ -35,12 +34,14 @@ import {
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix"; import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
import { import {
BehaviorSubject, BehaviorSubject,
Observable,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
fromEvent, fromEvent,
map, map,
of, of,
shareReplay,
startWith, startWith,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
@@ -92,16 +93,15 @@ export function useNameData(vm: MediaViewModel): NameData {
function observeTrackReference( function observeTrackReference(
participant: Participant, participant: Participant,
source: Track.Source, source: Track.Source,
): StateObservable<TrackReferenceOrPlaceholder> { ): Observable<TrackReferenceOrPlaceholder> {
return state( return observeParticipantMedia(participant).pipe(
observeParticipantMedia(participant).pipe(
map(() => ({ map(() => ({
participant, participant,
publication: participant.getTrackPublication(source), publication: participant.getTrackPublication(source),
source, source,
})), })),
distinctUntilKeyChanged("publication"), distinctUntilKeyChanged("publication"),
), shareReplay(1),
); );
} }
@@ -113,11 +113,11 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: StateObservable<TrackReferenceOrPlaceholder>; public readonly video: Observable<TrackReferenceOrPlaceholder>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning: StateObservable<boolean>; public readonly unencryptedWarning: Observable<boolean>;
public constructor( public constructor(
/** /**
@@ -138,15 +138,13 @@ abstract class BaseMediaViewModel extends ViewModel {
super(); super();
const audio = observeTrackReference(participant, audioSource); const audio = observeTrackReference(participant, audioSource);
this.video = observeTrackReference(participant, videoSource); this.video = observeTrackReference(participant, videoSource);
this.unencryptedWarning = state( this.unencryptedWarning = combineLatest(
combineLatest(
[audio, this.video], [audio, this.video],
(a, v) => (a, v) =>
callEncrypted && callEncrypted &&
(a.publication?.isEncrypted === false || (a.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false), v.publication?.isEncrypted === false),
).pipe(distinctUntilChanged()), ).pipe(distinctUntilChanged(), shareReplay(1));
);
} }
} }
@@ -165,27 +163,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = state( public readonly speaking = observeParticipantEvents(
observeParticipantEvents(
this.participant, this.participant,
ParticipantEvent.IsSpeakingChanged, ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking)), ).pipe(
map((p) => p.isSpeaking),
shareReplay(1),
); );
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
public readonly audioEnabled: StateObservable<boolean>; public readonly audioEnabled: Observable<boolean>;
/** /**
* Whether this participant is sending video. * Whether this participant is sending video.
*/ */
public readonly videoEnabled: StateObservable<boolean>; public readonly videoEnabled: Observable<boolean>;
private readonly _cropVideo = new BehaviorSubject(true); private readonly _cropVideo = new BehaviorSubject(true);
/** /**
* Whether the tile video should be contained inside the tile or be cropped to fit. * Whether the tile video should be contained inside the tile or be cropped to fit.
*/ */
public readonly cropVideo = state(this._cropVideo); public readonly cropVideo: Observable<boolean> = this._cropVideo;
public constructor( public constructor(
id: string, id: string,
@@ -202,12 +201,12 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Camera, Track.Source.Camera,
); );
const media = observeParticipantMedia(participant); const media = observeParticipantMedia(participant).pipe(shareReplay(1));
this.audioEnabled = state( this.audioEnabled = media.pipe(
media.pipe(map((m) => m.microphoneTrack?.isMuted === false)), map((m) => m.microphoneTrack?.isMuted === false),
); );
this.videoEnabled = state( this.videoEnabled = media.pipe(
media.pipe(map((m) => m.cameraTrack?.isMuted === false)), map((m) => m.cameraTrack?.isMuted === false),
); );
} }
@@ -223,8 +222,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror = state( public readonly mirror = this.video.pipe(
this.video.pipe(
switchMap((v) => { switchMap((v) => {
const track = v.publication?.track; const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
@@ -235,7 +233,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
map(() => facingModeFromLocalTrack(track).facingMode === "user"), map(() => facingModeFromLocalTrack(track).facingMode === "user"),
); );
}), }),
), shareReplay(1),
); );
/** /**
@@ -263,14 +261,14 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether we've disabled this participant's audio. * Whether we've disabled this participant's audio.
*/ */
public readonly locallyMuted = state(this._locallyMuted); public readonly locallyMuted: Observable<boolean> = this._locallyMuted;
private readonly _localVolume = new BehaviorSubject(1); private readonly _localVolume = new BehaviorSubject(1);
/** /**
* The volume to which we've set this participant's audio, as a scalar * The volume to which we've set this participant's audio, as a scalar
* multiplier. * multiplier.
*/ */
public readonly localVolume = state(this._localVolume); public readonly localVolume: Observable<number> = this._localVolume;
public constructor( public constructor(
id: string, id: string,

View File

@@ -1,49 +0,0 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ForwardRefExoticComponent,
ForwardRefRenderFunction,
PropsWithoutRef,
RefAttributes,
forwardRef,
} from "react";
// eslint-disable-next-line no-restricted-imports
import { Subscribe, RemoveSubscribe } from "@react-rxjs/core";
/**
* Wraps a React component that consumes Observables, resulting in a component
* that safely subscribes to its Observables before rendering. The component
* will return null until the subscriptions are created.
*/
export function subscribe<P, R>(
render: ForwardRefRenderFunction<R, P>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<R>> {
const Subscriber = forwardRef<R, { p: P }>(({ p }, ref) => (
<RemoveSubscribe>{render(p, ref)}</RemoveSubscribe>
));
Subscriber.displayName = "Subscriber";
// eslint-disable-next-line react/display-name
const OuterComponent = forwardRef<R, P>((p, ref) => (
<Subscribe>
<Subscriber ref={ref} p={p} />
</Subscribe>
));
// Copy over the component's display name, default props, etc.
Object.assign(OuterComponent, render);
return OuterComponent;
}

View File

@@ -28,14 +28,13 @@ import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?r
import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react";
import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { state, useStateObservable } from "@react-rxjs/core";
import { Observable, map, of } from "rxjs"; import { Observable, map, of } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css"; import styles from "./SpotlightTile.module.css";
import { subscribe } from "../state/subscribe";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
@@ -49,11 +48,11 @@ import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
// Screen share video is always enabled // Screen share video is always enabled
const videoEnabledDefault = state(of(true)); const videoEnabledDefault = of(true);
// Never mirror screen share video // Never mirror screen share video
const mirrorDefault = state(of(false)); const mirrorDefault = of(false);
// Never crop screen share video // Never crop screen share video
const cropVideoDefault = state(of(false)); const cropVideoDefault = of(false);
interface SpotlightItemProps { interface SpotlightItemProps {
vm: MediaViewModel; vm: MediaViewModel;
@@ -66,28 +65,28 @@ interface SpotlightItemProps {
snap: boolean; snap: boolean;
} }
const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>( const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const { displayName, nameTag } = useNameData(vm); const { displayName, nameTag } = useNameData(vm);
const video = useStateObservable(vm.video); const video = useObservableEagerState(vm.video);
const videoEnabled = useStateObservable( const videoEnabled = useObservableEagerState(
vm instanceof LocalUserMediaViewModel || vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel vm instanceof RemoteUserMediaViewModel
? vm.videoEnabled ? vm.videoEnabled
: videoEnabledDefault, : videoEnabledDefault,
); );
const mirror = useStateObservable( const mirror = useObservableEagerState(
vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault, vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault,
); );
const cropVideo = useStateObservable( const cropVideo = useObservableEagerState(
vm instanceof LocalUserMediaViewModel || vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel vm instanceof RemoteUserMediaViewModel
? vm.cropVideo ? vm.cropVideo
: cropVideoDefault, : cropVideoDefault,
); );
const unencryptedWarning = useStateObservable(vm.unencryptedWarning); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
@@ -124,6 +123,8 @@ const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>(
}, },
); );
SpotlightItem.displayName = "SpotlightItem";
interface Props { interface Props {
vms: MediaViewModel[]; vms: MediaViewModel[];
maximised: boolean; maximised: boolean;

View File

@@ -2847,14 +2847,6 @@
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80" resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg== integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==
"@react-rxjs/core@^0.10.7":
version "0.10.7"
resolved "https://registry.yarnpkg.com/@react-rxjs/core/-/core-0.10.7.tgz#09951f43a6c80892526ac13d51859098b0e74993"
integrity sha512-dornp8pUs9OcdqFKKRh9+I2FVe21gWufNun6RYU1ddts7kUy9i4Thvl0iqcPFbGY61cJQMAJF7dxixWMSD/A/A==
dependencies:
"@rx-state/core" "0.1.4"
use-sync-external-store "^1.0.0"
"@react-spring/animated@~9.7.3": "@react-spring/animated@~9.7.3":
version "9.7.3" version "9.7.3"
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f" resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.3.tgz#4211b1a6d48da0ff474a125e93c0f460ff816e0f"
@@ -3194,11 +3186,6 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4"
integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g== integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==
"@rx-state/core@0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@rx-state/core/-/core-0.1.4.tgz#586dde80be9dbdac31844006a0dcaa2bc7f35a5c"
integrity sha512-Z+3hjU2xh1HisLxt+W5hlYX/eGSDaXXP+ns82gq/PLZpkXLu0uwcNUh9RLY3Clq4zT+hSsA3vcpIGt6+UAb8rQ==
"@sentry-internal/browser-utils@8.13.0": "@sentry-internal/browser-utils@8.13.0":
version "8.13.0" version "8.13.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.13.0.tgz#b7c3bdd49d2382f60dde31745716d29dd419b6ba" resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.13.0.tgz#b7c3bdd49d2382f60dde31745716d29dd419b6ba"
@@ -8988,11 +8975,6 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0" detect-node-es "^1.1.0"
tslib "^2.0.0" tslib "^2.0.0"
use-sync-external-store@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
usehooks-ts@2.16.0: usehooks-ts@2.16.0:
version "2.16.0" version "2.16.0"
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.16.0.tgz#31deaa2f1147f65666aae925bd890b54e63b0d3f" resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-2.16.0.tgz#31deaa2f1147f65666aae925bd890b54e63b0d3f"