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:
@@ -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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
);
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
18
yarn.lock
18
yarn.lock
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user