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