mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-02 04:05:56 +00:00
Merge pull request #3346 from element-hq/robin/behaviors
Create a type-level distinction between raw Observables and Behaviors
This commit is contained in:
@@ -44,7 +44,7 @@ module.exports = {
|
||||
],
|
||||
// To encourage good usage of RxJS:
|
||||
"rxjs/no-exposed-subjects": "error",
|
||||
"rxjs/finnish": "error",
|
||||
"rxjs/finnish": ["error", { names: { "^this$": false } }],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import classNames from "classnames";
|
||||
import { useObservableState } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import styles from "./ReactionToggleButton.module.css";
|
||||
@@ -36,6 +34,7 @@ import {
|
||||
} from "../reactions";
|
||||
import { Modal } from "../Modal";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
raised: boolean;
|
||||
@@ -180,12 +179,8 @@ export function ReactionToggleButton({
|
||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
|
||||
const isHandRaised = useObservableState(
|
||||
vm.handsRaised$.pipe(map((v) => !!v[identifier])),
|
||||
);
|
||||
const canReact = useObservableState(
|
||||
vm.reactions$.pipe(map((v) => !v[identifier])),
|
||||
);
|
||||
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
|
||||
const canReact = !useBehavior(vm.reactions$)[identifier];
|
||||
|
||||
useEffect(() => {
|
||||
// Clear whenever the reactions menu state changes.
|
||||
|
||||
@@ -24,16 +24,16 @@ import {
|
||||
createContext,
|
||||
memo,
|
||||
use,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { fromEvent, map, startWith } from "rxjs";
|
||||
|
||||
import styles from "./Grid.module.css";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
@@ -155,11 +155,6 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
|
||||
);
|
||||
}
|
||||
|
||||
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
|
||||
startWith(null),
|
||||
map(() => window.innerHeight),
|
||||
);
|
||||
|
||||
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||
ref?: Ref<R>;
|
||||
model: LayoutModel;
|
||||
@@ -261,7 +256,13 @@ export function Grid<
|
||||
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
|
||||
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
|
||||
|
||||
const windowHeight = useObservableEagerState(windowHeightObservable$);
|
||||
const windowHeight = useSyncExternalStore(
|
||||
useCallback((onChange) => {
|
||||
window.addEventListener("resize", onChange);
|
||||
return (): void => window.removeEventListener("resize", onChange);
|
||||
}, []),
|
||||
useCallback(() => window.innerHeight, []),
|
||||
);
|
||||
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
|
||||
const [generation, setGeneration] = useState<number | null>(null);
|
||||
const [visibleTilesCallback, setVisibleTilesCallback] =
|
||||
|
||||
@@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
|
||||
import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import styles from "./OneOnOneLayout.module.css";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "one-on-one" layout, in which the remote participant
|
||||
@@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
const { tileWidth, tileHeight } = useMemo(
|
||||
() => arrangeTiles(width, height, 1),
|
||||
[width, height],
|
||||
|
||||
@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
import { type CallLayout } from "./CallLayout";
|
||||
import { type DragCallback, useUpdateLayout } from "./Grid";
|
||||
import styles from "./SpotlightExpandedLayout.module.css";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
/**
|
||||
* An implementation of the "expanded spotlight" layout, in which the spotlight
|
||||
@@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
const pipAlignmentValue = useBehavior(pipAlignment$);
|
||||
|
||||
const onDragPip: DragCallback = useCallback(
|
||||
({ xRatio, yRatio }) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout";
|
||||
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
|
||||
import styles from "./SpotlightPortraitLayout.module.css";
|
||||
import { useUpdateLayout, useVisibleTiles } from "./Grid";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface GridCSSProperties extends CSSProperties {
|
||||
"--grid-gap": string;
|
||||
@@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
width,
|
||||
model.grid.length,
|
||||
);
|
||||
const withIndicators =
|
||||
useObservableEagerState(model.spotlight.media$).length > 1;
|
||||
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -157,10 +157,13 @@ export function useLivekit(
|
||||
useObservableEagerState(
|
||||
useObservable(
|
||||
(room$) =>
|
||||
observeTrackReference$(
|
||||
room$.pipe(map(([room]) => room.localParticipant)),
|
||||
Track.Source.Camera,
|
||||
).pipe(
|
||||
room$.pipe(
|
||||
switchMap(([room]) =>
|
||||
observeTrackReference$(
|
||||
room.localParticipant,
|
||||
Track.Source.Camera,
|
||||
),
|
||||
),
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication?.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
} from "react";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "../ClientContext";
|
||||
import { ElementCallReactionEventType, type ReactionOption } from ".";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface ReactionsSenderContextType {
|
||||
supportsReactions: boolean;
|
||||
@@ -70,7 +70,7 @@ export const ReactionsSenderProvider = ({
|
||||
[memberships, myUserId, myDeviceId],
|
||||
);
|
||||
|
||||
const reactions = useObservableEagerState(vm.reactions$);
|
||||
const reactions = useBehavior(vm.reactions$);
|
||||
const myReaction = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
@@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({
|
||||
[myMembershipIdentifier, reactions],
|
||||
);
|
||||
|
||||
const handsRaised = useObservableEagerState(vm.handsRaised$);
|
||||
const handsRaised = useBehavior(vm.handsRaised$);
|
||||
const myRaisedHand = useMemo(
|
||||
() =>
|
||||
myMembershipIdentifier !== undefined
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
@@ -43,6 +42,7 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
@@ -141,7 +141,7 @@ function createGroupCallView(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(of([]));
|
||||
).withMemberships(constant([]));
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -110,7 +110,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
useAudioContext({
|
||||
sounds: callEventAudioSounds,
|
||||
|
||||
@@ -25,7 +25,7 @@ import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import {
|
||||
@@ -112,6 +112,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -251,7 +252,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
room: livekitRoom,
|
||||
});
|
||||
|
||||
const muteAllAudio = useObservableEagerState(muteAllAudio$);
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
|
||||
// This seems like it might be enough logic to use move it into the call view model?
|
||||
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
||||
@@ -302,15 +303,15 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const windowMode = useObservableEagerState(vm.windowMode$);
|
||||
const layout = useObservableEagerState(vm.layout$);
|
||||
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
|
||||
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
|
||||
const gridMode = useObservableEagerState(vm.gridMode$);
|
||||
const showHeader = useObservableEagerState(vm.showHeader$);
|
||||
const showFooter = useObservableEagerState(vm.showFooter$);
|
||||
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$);
|
||||
const gridMode = useBehavior(vm.gridMode$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
@@ -527,16 +528,12 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
targetHeight,
|
||||
model,
|
||||
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
||||
const spotlightExpanded = useObservableEagerState(
|
||||
vm.spotlightExpanded$,
|
||||
);
|
||||
const onToggleExpanded = useObservableEagerState(
|
||||
vm.toggleSpotlightExpanded$,
|
||||
);
|
||||
const showSpeakingIndicatorsValue = useObservableEagerState(
|
||||
const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
|
||||
const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
|
||||
const showSpeakingIndicatorsValue = useBehavior(
|
||||
vm.showSpeakingIndicators$,
|
||||
);
|
||||
const showSpotlightIndicatorsValue = useObservableEagerState(
|
||||
const showSpotlightIndicatorsValue = useBehavior(
|
||||
vm.showSpotlightIndicators$,
|
||||
);
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { useObservableState } from "observable-hooks";
|
||||
|
||||
import styles from "./ReactionsOverlay.module.css";
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
|
||||
const reactionsIcons = useObservableState(vm.visibleReactions$);
|
||||
const reactionsIcons = useBehavior(vm.visibleReactions$);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{reactionsIcons?.map(({ sender, emoji, startX }) => (
|
||||
{reactionsIcons.map(({ sender, emoji, startX }) => (
|
||||
<span
|
||||
// Reactions effects are considered presentation elements. The reaction
|
||||
// is also present on the sender's tile, which assistive technology can
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
@@ -34,6 +33,7 @@ import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { useSubmitRageshake } from "./submit-rageshake";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -112,7 +112,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
// rather than the input section.
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
// If we are on iOS we will show a button to open the native audio device picker.
|
||||
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$);
|
||||
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
||||
|
||||
const audioTab: Tab<SettingsTab> = {
|
||||
key: "audio",
|
||||
|
||||
@@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { BehaviorSubject, type Observable } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
export class Setting<T> {
|
||||
public constructor(
|
||||
@@ -38,7 +39,7 @@ export class Setting<T> {
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
public readonly value$: Observable<T>;
|
||||
public readonly value$: Behavior<T>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
this._value$.next(value);
|
||||
@@ -53,7 +54,7 @@ export class Setting<T> {
|
||||
* React hook that returns a settings's current value and a setter.
|
||||
*/
|
||||
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
|
||||
return [useObservableEagerState(setting.value$), setting.setValue];
|
||||
return [useBehavior(setting.value$), setting.setValue];
|
||||
}
|
||||
|
||||
// null = undecided
|
||||
|
||||
26
src/state/Behavior.ts
Normal file
26
src/state/Behavior.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
/**
|
||||
* A stateful, read-only reactive value. As an Observable, it is "hot" and
|
||||
* always replays the current value upon subscription.
|
||||
*
|
||||
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
|
||||
* provide a way to imperatively set new values. For more info on the
|
||||
* distinction between Behaviors and Observables, see
|
||||
* https://monoid.dk/post/behaviors-and-streams-why-both/.
|
||||
*/
|
||||
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
|
||||
|
||||
/**
|
||||
* Creates a Behavior which never changes in value.
|
||||
*/
|
||||
export function constant<T>(value: T): Behavior<T> {
|
||||
return new BehaviorSubject(value);
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
of,
|
||||
skip,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
@@ -75,10 +75,18 @@ import {
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
vi.mock("rxjs", async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
// Disable interval Observables for the following tests since the test
|
||||
// scheduler will loop on them forever and never call the test 'done'
|
||||
interval: (): Observable<number> => NEVER,
|
||||
}));
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
|
||||
@@ -157,9 +165,10 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? of(undefined),
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight?.map((vm) => vm.id),
|
||||
@@ -178,7 +187,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
@@ -212,8 +222,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
}
|
||||
|
||||
function withCallViewModel(
|
||||
remoteParticipants$: Observable<RemoteParticipant[]>,
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>,
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
||||
connectionState$: Observable<ECConnectionState>,
|
||||
speaking: Map<Participant, Observable<boolean>>,
|
||||
mediaDevices: MediaDevices,
|
||||
@@ -291,7 +301,7 @@ function withCallViewModel(
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const participantInputMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
@@ -300,12 +310,12 @@ test("participants are retained during a focus switch", () => {
|
||||
const expectedLayoutMarbles = " a";
|
||||
|
||||
withCallViewModel(
|
||||
hot(participantInputMarbles, {
|
||||
behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
hot(connectionInputMarbles, {
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
behavior(connectionInputMarbles, {
|
||||
c: ConnectionState.Connected,
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
@@ -328,7 +338,7 @@ test("participants are retained during a focus switch", () => {
|
||||
});
|
||||
|
||||
test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const participantInputMarbles = " abcda-ba";
|
||||
@@ -341,13 +351,13 @@ test("screen sharing activates spotlight layout", () => {
|
||||
const expectedLayoutMarbles = " abcdaefeg";
|
||||
const expectedShowSpeakingMarbles = "y----nyny";
|
||||
withCallViewModel(
|
||||
hot(participantInputMarbles, {
|
||||
behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -413,7 +423,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
});
|
||||
|
||||
test("participants stay in the same order unless to appear/disappear", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const visibilityInputMarbles = "a";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
|
||||
@@ -426,13 +436,22 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -472,7 +491,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
});
|
||||
|
||||
test("participants adjust order when space becomes constrained", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Start with all tiles on screen then shrink to 3
|
||||
const visibilityInputMarbles = "a-b";
|
||||
// Bob and Dave speak
|
||||
@@ -484,12 +503,18 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
const expectedLayoutMarbles = " a-b";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -523,7 +548,7 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
});
|
||||
|
||||
test("spotlight speakers swap places", () => {
|
||||
withTestScheduler(({ hot, schedule, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Go immediately into spotlight mode for the test
|
||||
const modeInputMarbles = " s";
|
||||
// First Bob speaks, then Dave, then Alice
|
||||
@@ -537,13 +562,22 @@ test("spotlight speakers swap places", () => {
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant, daveParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map([
|
||||
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
[
|
||||
aliceParticipant,
|
||||
behavior(aSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
bobParticipant,
|
||||
behavior(bSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
[
|
||||
daveParticipant,
|
||||
behavior(dSpeakingInputMarbles, { y: true, n: false }),
|
||||
],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
@@ -587,8 +621,8 @@ test("layout enters picture-in-picture mode when requested", () => {
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -629,8 +663,8 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
const expectedLayoutMarbles = "abcbada";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -678,7 +712,7 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
});
|
||||
|
||||
test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// iterate through a number of combinations of participants and MatrixRTC memberships
|
||||
// Bob never has an MatrixRTC membership
|
||||
const scenarioInputMarbles = " abcdec";
|
||||
@@ -686,14 +720,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
const expectedLayoutMarbles = "a-bc-b";
|
||||
|
||||
withCallViewModel(
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
d: [aliceParticipant, daveParticipant, bobParticipant],
|
||||
e: [aliceParticipant, daveParticipant, bobSharingScreen],
|
||||
}),
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [],
|
||||
c: [aliceRtcMember],
|
||||
@@ -734,17 +768,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
try {
|
||||
// enable the setting:
|
||||
showNonMemberTiles.setValue(true);
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = " abc";
|
||||
const expectedLayoutMarbles = "abc";
|
||||
|
||||
withCallViewModel(
|
||||
hot(scenarioInputMarbles, {
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
c: [aliceParticipant, bobParticipant],
|
||||
}),
|
||||
of([]), // No one joins the MatrixRTC session
|
||||
constant([]), // No one joins the MatrixRTC session
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -779,15 +813,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
});
|
||||
|
||||
it("should show at least one tile per MatrixRTCSession", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// iterate through some combinations of MatrixRTC memberships
|
||||
const scenarioInputMarbles = " abcd";
|
||||
// There should always be one tile for each MatrixRTCSession
|
||||
const expectedLayoutMarbles = "abcd";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, daveRtcMember],
|
||||
@@ -829,13 +863,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
||||
});
|
||||
|
||||
test("should disambiguate users with the same displayname", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "abcde";
|
||||
const expectedLayoutMarbles = "abcde";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [aliceRtcMember],
|
||||
c: [aliceRtcMember, aliceDoppelgangerRtcMember],
|
||||
@@ -846,50 +880,46 @@ test("should disambiguate users with the same displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, alice.rawDisplayName],
|
||||
]),
|
||||
// The second alice joins.
|
||||
c: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
]),
|
||||
// Bob also joins
|
||||
d: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceId, "Alice (@alice:example.org)"],
|
||||
[aliceDoppelgangerId, "Alice (@alice2:example.org)"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
// Alice leaves, and the displayname should reset.
|
||||
e: new Map([
|
||||
[carolId, carol.userId],
|
||||
[aliceDoppelgangerId, "Alice"],
|
||||
[bobId, bob.rawDisplayName],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should disambiguate users with invisible characters", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
|
||||
}),
|
||||
@@ -897,36 +927,32 @@ test("should disambiguate users with invisible characters", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Bobs join, and should handle zero width hacks.
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
[bobId, `Bob (${bob.userId})`],
|
||||
[
|
||||
bobZeroWidthSpaceId,
|
||||
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
|
||||
],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should strip RTL characters from displayname", () => {
|
||||
withTestScheduler(({ hot, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
const scenarioInputMarbles = "ab";
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
hot(scenarioInputMarbles, {
|
||||
constant([]),
|
||||
behavior(scenarioInputMarbles, {
|
||||
a: [],
|
||||
b: [daveRtcMember, daveRTLRtcMember],
|
||||
}),
|
||||
@@ -934,22 +960,18 @@ test("should strip RTL characters from displayname", () => {
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
|
||||
// Carol has no displayname - So userId is used.
|
||||
a: new Map([[carolId, carol.userId]]),
|
||||
// Both Dave's join. Since after stripping
|
||||
b: new Map([
|
||||
[carolId, carol.userId],
|
||||
// Not disambiguated
|
||||
[daveId, "Dave"],
|
||||
// This one is, since it's using RTL.
|
||||
[daveRTLId, `evaD (${daveRTL.userId})`],
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -961,8 +983,8 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
const expectedLayoutMarbles = "ab";
|
||||
|
||||
withCallViewModel(
|
||||
of([aliceParticipant, bobParticipant]),
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
constant([aliceParticipant, bobParticipant]),
|
||||
constant([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
@@ -1036,8 +1058,8 @@ test("audio output changes when toggling earpiece mode", () => {
|
||||
const expectedTargetStateMarbles = " sese";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
of([]),
|
||||
constant([]),
|
||||
constant([]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
devices,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import {
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
@@ -74,11 +74,11 @@ export interface MediaDevice<Label, Selected> {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available$: Observable<Map<string, Label>>;
|
||||
available$: Behavior<Map<string, Label>>;
|
||||
/**
|
||||
* The selected device.
|
||||
*/
|
||||
selected$: Observable<Selected | undefined>;
|
||||
selected$: Behavior<Selected | undefined>;
|
||||
/**
|
||||
* Selects a new device.
|
||||
*/
|
||||
@@ -94,35 +94,36 @@ export interface MediaDevice<Label, Selected> {
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ =
|
||||
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
|
||||
function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Observable<boolean>,
|
||||
usingNames$: Behavior<boolean>,
|
||||
scope: ObservableScope,
|
||||
): Observable<MediaDeviceInfo[]> {
|
||||
): Behavior<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
const devices$ = createMediaDeviceObserver(kind, logError, false);
|
||||
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
|
||||
|
||||
return usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
return scope.behavior(
|
||||
usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
),
|
||||
startWith([]),
|
||||
scope.state(),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,34 +162,33 @@ function selectDevice$<Label>(
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
|
||||
public readonly available$ = this.availableRaw$.pipe(
|
||||
map(buildDeviceMap),
|
||||
this.scope.state(),
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioInputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map(
|
||||
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
|
||||
),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
@@ -196,7 +196,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
@@ -208,51 +208,45 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
class AudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioOutputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, audioOutputSetting.value$).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
public select(id: string): void {
|
||||
audioOutputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
@@ -273,30 +267,32 @@ class ControlledAudioOutput
|
||||
this.scope,
|
||||
);
|
||||
|
||||
public readonly available$ = combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
public readonly available$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
|
||||
return available;
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
return available;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
private readonly deviceSelection$ = new Subject<string>();
|
||||
|
||||
@@ -304,24 +300,26 @@ class ControlledAudioOutput
|
||||
this.deviceSelection$.next(id);
|
||||
}
|
||||
|
||||
public readonly selected$ = combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.selected$.subscribe((device) => {
|
||||
@@ -346,26 +344,22 @@ class ControlledAudioOutput
|
||||
}
|
||||
|
||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(map(buildDeviceMap));
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
videoInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
this.scope.state(),
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
|
||||
map(buildDeviceMap),
|
||||
),
|
||||
);
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, videoInputSetting.value$).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
videoInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
@@ -393,12 +387,10 @@ export class MediaDevices {
|
||||
// you to do to receive device names in lieu of a more explicit permissions
|
||||
// API. This flag never resets to false, because once permissions are granted
|
||||
// the first time, the user won't be prompted again until reload of the page.
|
||||
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
this.scope.state(),
|
||||
private readonly usingNames$ = this.scope.behavior(
|
||||
this.deviceNamesRequest$.pipe(map(() => true)),
|
||||
false,
|
||||
);
|
||||
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
|
||||
@@ -51,26 +51,19 @@ import { accumulate } from "../utils/observable";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant$: Observable<Participant | undefined>,
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReferenceOrPlaceholder | undefined> {
|
||||
return participant$.pipe(
|
||||
switchMap((p) => {
|
||||
if (p) {
|
||||
return observeParticipantMedia(p).pipe(
|
||||
map(() => ({
|
||||
participant: p,
|
||||
publication: p.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
);
|
||||
} else {
|
||||
return of(undefined);
|
||||
}
|
||||
}),
|
||||
): Observable<TrackReferenceOrPlaceholder> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +75,7 @@ export function observeRtpStreamStats$(
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(of(participant), source),
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
@@ -223,19 +216,31 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
|
||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning$: Observable<boolean>;
|
||||
public readonly unencryptedWarning$: Behavior<boolean>;
|
||||
|
||||
public readonly encryptionStatus$: Observable<EncryptionStatus>;
|
||||
public readonly encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
|
||||
/**
|
||||
* Whether this media corresponds to the local participant.
|
||||
*/
|
||||
public abstract readonly local: boolean;
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReferenceOrPlaceholder | undefined> {
|
||||
return this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === undefined ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
@@ -257,84 +262,85 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom: LivekitRoom,
|
||||
public readonly displayname$: Observable<string>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
) {
|
||||
super();
|
||||
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
|
||||
this.scope.state(),
|
||||
);
|
||||
this.unencryptedWarning$ = combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
).pipe(this.scope.state());
|
||||
|
||||
this.encryptionStatus$ = this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
this.unencryptedWarning$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
),
|
||||
);
|
||||
|
||||
this.encryptionStatus$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
this.scope.state(),
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -354,31 +360,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
|
||||
map((p) => p.isSpeaking),
|
||||
)
|
||||
: of(false),
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled$: Observable<boolean>;
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
public readonly videoEnabled$: Observable<boolean>;
|
||||
public readonly videoEnabled$: Behavior<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$: Observable<boolean> = this._cropVideo$;
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
@@ -386,9 +394,9 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
public readonly handRaised$: Observable<Date | null>,
|
||||
public readonly reaction$: Observable<ReactionOption | null>,
|
||||
displayName$: Behavior<string>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -398,18 +406,19 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
);
|
||||
|
||||
const media$ = participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
this.scope.state(),
|
||||
const media$ = this.scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
this.audioEnabled$ = media$.pipe(
|
||||
map((m) => m?.microphoneTrack?.isMuted === false),
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
);
|
||||
this.videoEnabled$ = media$.pipe(
|
||||
map((m) => m?.cameraTrack?.isMuted === false),
|
||||
this.videoEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,18 +445,19 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
this.scope.state(),
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -460,12 +470,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Observable<LocalParticipant | undefined>,
|
||||
participant$: Behavior<LocalParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
displayName$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -473,7 +483,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
@@ -512,42 +522,42 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume$: Observable<number> = merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
this.scope.state(),
|
||||
public readonly localVolume$ = this.scope.behavior<number>(
|
||||
merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
|
||||
map((volume) => volume === 0),
|
||||
this.scope.state(),
|
||||
public readonly locallyMuted$ = this.scope.behavior<boolean>(
|
||||
this.localVolume$.pipe(map((volume) => volume === 0)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
@@ -556,9 +566,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
handRaised$: Observable<Date | null>,
|
||||
reaction$: Observable<ReactionOption | null>,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
id,
|
||||
@@ -621,7 +631,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayname$: Behavior<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
|
||||
@@ -26,11 +26,9 @@ test("muteAllAudio$", () => {
|
||||
|
||||
muteAllAudio.unsubscribe();
|
||||
|
||||
expect(valueMock).toHaveBeenCalledTimes(6);
|
||||
expect(valueMock).toHaveBeenCalledTimes(4);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
|
||||
expect(valueMock).toHaveBeenNthCalledWith(4, true); // muteAllAudioSetting.setValue(true);
|
||||
});
|
||||
|
||||
@@ -9,11 +9,14 @@ import { combineLatest, startWith } from "rxjs";
|
||||
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
||||
import { globalScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* This can transition into sth more complete: `GroupCallViewModel.ts`
|
||||
*/
|
||||
export const muteAllAudio$ = combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
export const muteAllAudio$ = globalScope.behavior(
|
||||
combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,15 +6,19 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
type Observable,
|
||||
shareReplay,
|
||||
Subject,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||
|
||||
const nothing = Symbol("nothing");
|
||||
|
||||
/**
|
||||
* A scope which limits the execution lifetime of its bound Observables.
|
||||
*/
|
||||
@@ -31,20 +35,31 @@ export class ObservableScope {
|
||||
return this.bindImpl;
|
||||
}
|
||||
|
||||
private readonly stateImpl: MonoTypeOperator = (o$) =>
|
||||
o$.pipe(
|
||||
this.bind(),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Transforms an Observable into a hot state Observable which replays its
|
||||
* latest value upon subscription, skips updates with identical values, and
|
||||
* is bound to this scope.
|
||||
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||
* Observable must synchronously emit an initial value.
|
||||
*/
|
||||
public state(): MonoTypeOperator {
|
||||
return this.stateImpl;
|
||||
public behavior<T>(
|
||||
setValue$: Observable<T>,
|
||||
initialValue: T | typeof nothing = nothing,
|
||||
): Behavior<T> {
|
||||
const subject$ = new BehaviorSubject(initialValue);
|
||||
// Push values from the Observable into the BehaviorSubject.
|
||||
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||
// they will no longer re-emit their current value upon subscription. We want
|
||||
// to support Observables that complete (for example `of({})`), so we have to
|
||||
// take care to not propagate the completion event.
|
||||
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
|
||||
next(value) {
|
||||
subject$.next(value);
|
||||
},
|
||||
error(err: unknown) {
|
||||
subject$.error(err);
|
||||
},
|
||||
});
|
||||
if (subject$.value === nothing)
|
||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||
return subject$ as Behavior<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,3 +70,8 @@ export class ObservableScope {
|
||||
this.ended$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global scope, a scope which never ends.
|
||||
*/
|
||||
export const globalScope = new ObservableScope();
|
||||
|
||||
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
@@ -18,15 +17,15 @@ function createId(): string {
|
||||
export class GridTileViewModel extends ViewModel {
|
||||
public readonly id = createId();
|
||||
|
||||
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class SpotlightTileViewModel extends ViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Observable<MediaViewModel[]>,
|
||||
public readonly maximised$: Observable<boolean>,
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type RemoteTrackPublication } from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { of } from "rxjs";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
@@ -17,6 +16,7 @@ import { mockRtcMembership, withRemoteMedia } from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import type { CallViewModel } from "../state/CallViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -53,13 +53,13 @@ test("GridTile is accessible", async () => {
|
||||
memberships: [],
|
||||
} as unknown as MatrixRTCSession;
|
||||
const cVm = {
|
||||
reactions$: of({}),
|
||||
handsRaised$: of({}),
|
||||
reactions$: constant({}),
|
||||
handsRaised$: constant({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(of(vm))}
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
ToggleMenuItem,
|
||||
Menu,
|
||||
} from "@vector-im/compound-web";
|
||||
import { useObservableEagerState, useObservableState } from "observable-hooks";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
@@ -49,6 +49,7 @@ import { useLatest } from "../useLatest";
|
||||
import { type GridTileViewModel } from "../state/TileViewModel";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -81,19 +82,19 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
@@ -101,8 +102,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
const handRaised = useBehavior(vm.handRaised$);
|
||||
const reaction = useBehavior(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
@@ -205,8 +206,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const mirror = useBehavior(vm.mirror$);
|
||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
@@ -256,8 +257,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
|
||||
const localVolume = useObservableEagerState(vm.localVolume$);
|
||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||
const localVolume = useBehavior(vm.localVolume$);
|
||||
const onSelectMute = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
@@ -328,8 +329,8 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const displayName = useBehavior(media.displayName$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,6 @@ import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
withRemoteMedia,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -44,7 +44,12 @@ test("SpotlightTile is accessible", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm1, vm2]),
|
||||
constant(false),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type Observable, map } from "rxjs";
|
||||
import { useObservableEagerState, useObservableRef } from "observable-hooks";
|
||||
import { useObservableRef } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
@@ -43,6 +43,7 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -73,7 +74,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const mirror = useBehavior(vm.mirror$);
|
||||
return <MediaView mirror={mirror} {...props} />;
|
||||
};
|
||||
|
||||
@@ -87,8 +88,8 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
@@ -130,10 +131,10 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
@@ -200,8 +201,8 @@ export const SpotlightTile: FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
|
||||
@@ -10,12 +10,12 @@ import { type FC } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { useAudioContext } from "./useAudioContext";
|
||||
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
|
||||
import { mockMediaDevices } from "./utils/test";
|
||||
import { constant } from "./state/Behavior";
|
||||
|
||||
const staticSounds = Promise.resolve({
|
||||
aSound: new ArrayBuffer(0),
|
||||
@@ -128,8 +128,8 @@ test("will use the correct device", () => {
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: false }),
|
||||
available$: constant(new Map<never, never>()),
|
||||
selected$: constant({ id: "chosen-device", virtualEarpiece: false }),
|
||||
select: () => {},
|
||||
},
|
||||
})}
|
||||
@@ -161,8 +161,8 @@ test("will use the pan if earpiece is selected", async () => {
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: true }),
|
||||
available$: constant(new Map<never, never>()),
|
||||
selected$: constant({ id: "chosen-device", virtualEarpiece: true }),
|
||||
select: () => {},
|
||||
},
|
||||
})}
|
||||
|
||||
25
src/useBehavior.ts
Normal file
25
src/useBehavior.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
import { type Behavior } from "./state/Behavior";
|
||||
|
||||
/**
|
||||
* React hook which reactively reads the value of a behavior.
|
||||
*/
|
||||
export function useBehavior<T>(behavior: Behavior<T>): T {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
const s = behavior.subscribe(onChange);
|
||||
return (): void => s.unsubscribe();
|
||||
},
|
||||
[behavior],
|
||||
);
|
||||
const getValue = useCallback(() => behavior.value, [behavior]);
|
||||
return useSyncExternalStore(subscribe, getValue);
|
||||
}
|
||||
@@ -47,6 +47,8 @@ import {
|
||||
} from "../config/ConfigOptions";
|
||||
import { Config } from "../config/Config";
|
||||
import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
export function withFakeTimers(continuation: () => void): void {
|
||||
vi.useFakeTimers();
|
||||
@@ -67,6 +69,11 @@ export interface OurRunHelpers extends RunHelpers {
|
||||
* diagram.
|
||||
*/
|
||||
schedule: (marbles: string, actions: Record<string, () => void>) => void;
|
||||
behavior<T = string>(
|
||||
marbles: string,
|
||||
values?: { [marble: string]: T },
|
||||
error?: unknown,
|
||||
): Behavior<T>;
|
||||
}
|
||||
|
||||
interface TestRunnerGlobal {
|
||||
@@ -82,6 +89,7 @@ export function withTestScheduler(
|
||||
const scheduler = new TestScheduler((actual, expected) => {
|
||||
expect(actual).deep.equals(expected);
|
||||
});
|
||||
const scope = new ObservableScope();
|
||||
// we set the test scheduler as a global so that you can watch it in a debugger
|
||||
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
|
||||
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
|
||||
@@ -98,8 +106,36 @@ export function withTestScheduler(
|
||||
// Run the actions and verify that none of them error
|
||||
helpers.expectObservable(actionsObservable$).toBe(marbles, results);
|
||||
},
|
||||
behavior<T>(
|
||||
marbles: string,
|
||||
values?: { [marble: string]: T },
|
||||
error?: unknown,
|
||||
) {
|
||||
// Generate a hot Observable with helpers.hot and use it as a Behavior.
|
||||
// To do this, we need to ensure that the initial value emits
|
||||
// synchronously upon subscription. The issue is that helpers.hot emits
|
||||
// frame 0 of the marble diagram *asynchronously*, only once we return
|
||||
// from the continuation, so we need to splice out the initial marble
|
||||
// and turn it into a proper initial value.
|
||||
const initialMarbleIndex = marbles.search(/[^ ]/);
|
||||
if (initialMarbleIndex === -1)
|
||||
throw new Error("Behavior must have an initial value");
|
||||
const initialMarble = marbles[initialMarbleIndex];
|
||||
const initialValue =
|
||||
values === undefined ? (initialMarble as T) : values[initialMarble];
|
||||
// The remainder of the marble diagram should start on frame 1
|
||||
return scope.behavior(
|
||||
helpers.hot(
|
||||
`-${marbles.slice(initialMarbleIndex + 1)}`,
|
||||
values,
|
||||
error,
|
||||
),
|
||||
initialValue,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
scope.end();
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
@@ -211,14 +247,14 @@ export async function withLocalMedia(
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"local",
|
||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||
of(localParticipant),
|
||||
constant(localParticipant),
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({ localParticipant }),
|
||||
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
of(null),
|
||||
of(null),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
@@ -255,9 +291,9 @@ export async function withRemoteMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
of(null),
|
||||
of(null),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
@@ -299,7 +335,7 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
}
|
||||
|
||||
public withMemberships(
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>,
|
||||
): MockRTCSession {
|
||||
rtcMembers$.subscribe((m) => {
|
||||
const old = this.memberships;
|
||||
|
||||
Reference in New Issue
Block a user