Merge pull request #2381 from robintown/observable-hooks

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

View File

@@ -38,15 +38,6 @@ module.exports = {
"jsx-a11y/media-has-caption": "off",
// 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: {

View File

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

View File

@@ -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<Bounds>,
minBounds: Observable<Bounds>,
floatingAlignment: BehaviorSubject<Alignment>,
): GridLayoutSystems => ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
@@ -58,18 +57,16 @@ 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<StateObservable<Alignment>>(() =>
state(
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,
),
),
),
),
);
const [generation] = useReactiveState<number>(
(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
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

View File

@@ -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,8 +142,7 @@ export interface InCallViewProps {
onShareClick: (() => void) | null;
}
export const InCallView: FC<InCallViewProps> = subscribe(
({
export const InCallView: FC<InCallViewProps> = ({
client,
matrixInfo,
rtcSession,
@@ -223,9 +221,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
setLegacyLayout("grid");
widget!.api.transport.reply(ev.detail, {});
};
const onSpotlightLayout = (
ev: CustomEvent<IWidgetApiRequest>,
): void => {
const onSpotlightLayout = (ev: CustomEvent<IWidgetApiRequest>): void => {
setLegacyLayout("spotlight");
widget!.api.transport.reply(ev.detail, {});
};
@@ -237,10 +233,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
);
return (): void => {
widget!.lazyActions.off(
ElementWidgetActions.TileLayout,
onTileLayout,
);
widget!.lazyActions.off(ElementWidgetActions.TileLayout, onTileLayout);
widget!.lazyActions.off(
ElementWidgetActions.SpotlightLayout,
onSpotlightLayout,
@@ -259,8 +252,8 @@ export const InCallView: FC<InCallViewProps> = subscribe(
matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState,
);
const items = useStateObservable(vm.tiles);
const layout = useStateObservable(vm.layout);
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
@@ -326,7 +319,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
() => new BehaviorSubject(defaultAlignment),
);
const { fixed, scrolling } = useInitial(() =>
gridLayoutSystems(state(gridBoundsObservable), floatingAlignment),
gridLayoutSystems(gridBoundsObservable, floatingAlignment),
);
const setGridMode = useCallback(
@@ -583,9 +576,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
<RoomAudioRenderer />
{renderContent()}
{footer}
{!noControls && (
<RageshakeRequestModal {...rageshakeRequestModalProps} />
)}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
@@ -596,5 +587,4 @@ export const InCallView: FC<InCallViewProps> = subscribe(
/>
</div>
);
},
);
};

View File

@@ -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";
@@ -183,7 +182,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) =>
@@ -259,9 +258,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
@@ -334,8 +333,7 @@ export class CallViewModel extends ViewModel {
},
);
private readonly mediaItems: StateObservable<MediaItem[]> = state(
combineLatest([
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
@@ -343,11 +341,7 @@ export class CallViewModel extends ViewModel {
scan(
(
prevItems,
[
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
],
[remoteParticipants, { participant: localParticipant }, duplicateTiles],
) => {
let allGhosts = true;
@@ -380,12 +374,7 @@ export class CallViewModel extends ViewModel {
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
p,
this.encrypted,
),
new ScreenShare(screenShareId, member, p, this.encrypted),
];
}
}
@@ -393,8 +382,6 @@ export class CallViewModel extends ViewModel {
}.bind(this)(),
);
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
// If every item is a ghost, that probably means we're still connecting
// and shouldn't bother showing anything yet
return allGhosts ? new Map() : newItems;
@@ -405,7 +392,7 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => {
for (const t of ts) t.destroy();
}),
),
shareReplay(1),
);
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
@@ -507,14 +494,15 @@ export class CallViewModel extends ViewModel {
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode = state(this._gridMode);
public readonly gridMode: Observable<GridMode> = this._gridMode;
public setGridMode(value: GridMode): void {
this._gridMode.next(value);
}
public readonly layout: StateObservable<Layout> = state(
combineLatest([this._gridMode, this.windowMode], (gridMode, windowMode) => {
public readonly layout: Observable<Layout> = combineLatest(
[this._gridMode, this.windowMode],
(gridMode, windowMode) => {
switch (windowMode) {
case "full screen":
throw new Error("unimplemented");
@@ -543,16 +531,15 @@ 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<TileDescriptor<MediaViewModel>[]> =
state(
public readonly tiles: Observable<TileDescriptor<MediaViewModel>[]> =
combineLatest([
this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant),
@@ -646,7 +633,7 @@ export class CallViewModel extends ViewModel {
finalizeValue((ts) => {
for (const t of ts) t.data.destroy();
}),
),
shareReplay(1),
);
public constructor(

View File

@@ -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<TrackReferenceOrPlaceholder> {
return state(
observeParticipantMedia(participant).pipe(
): Observable<TrackReferenceOrPlaceholder> {
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<TrackReferenceOrPlaceholder>;
public readonly video: Observable<TrackReferenceOrPlaceholder>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
public readonly unencryptedWarning: StateObservable<boolean>;
public readonly unencryptedWarning: Observable<boolean>;
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(
this.unencryptedWarning = combineLatest(
[audio, this.video],
(a, v) =>
callEncrypted &&
(a.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.
*/
public readonly speaking = state(
observeParticipantEvents(
public readonly speaking = observeParticipantEvents(
this.participant,
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).
*/
public readonly audioEnabled: StateObservable<boolean>;
public readonly audioEnabled: Observable<boolean>;
/**
* Whether this participant is sending video.
*/
public readonly videoEnabled: StateObservable<boolean>;
public readonly videoEnabled: Observable<boolean>;
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<boolean> = 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,8 +222,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = state(
this.video.pipe(
public readonly mirror = this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
@@ -235,7 +233,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
),
shareReplay(1),
);
/**
@@ -263,14 +261,14 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/**
* 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);
/**
* 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<number> = this._localVolume;
public constructor(
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 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<SpotlightItemProps, HTMLDivElement>(
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(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<SpotlightItemProps, HTMLDivElement>(
},
);
SpotlightItem.displayName = "SpotlightItem";
interface Props {
vms: MediaViewModel[];
maximised: boolean;

View File

@@ -2847,14 +2847,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"
@@ -3194,11 +3186,6 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4"
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":
version "8.13.0"
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"
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"