Replace many usages of useObservableEagerState with useBehavior
This hook is simpler in its implementation (therefore hopefully more correct & performant) and enforces a type-level distinction between raw Observables and Behaviors.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { BehaviorSubject, Observable } from "rxjs";
|
import { BehaviorSubject, distinctUntilChanged, Observable } from "rxjs";
|
||||||
|
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ Observable.prototype.behavior = function <T>(
|
|||||||
): Behavior<T> {
|
): Behavior<T> {
|
||||||
const subject$ = new BehaviorSubject<T | typeof nothing>(nothing);
|
const subject$ = new BehaviorSubject<T | typeof nothing>(nothing);
|
||||||
// Push values from the Observable into the BehaviorSubject
|
// Push values from the Observable into the BehaviorSubject
|
||||||
this.pipe(scope.bind()).subscribe(subject$);
|
this.pipe(scope.bind(), distinctUntilChanged()).subscribe(subject$);
|
||||||
if (subject$.value === nothing)
|
if (subject$.value === nothing)
|
||||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||||
return subject$ as Behavior<T>;
|
return subject$ as Behavior<T>;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ 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 { constant } from "./Behavior";
|
||||||
|
|
||||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||||
@@ -157,9 +158,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 +180,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),
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ class ScreenShare {
|
|||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
liveKitRoom: LivekitRoom,
|
liveKitRoom: LivekitRoom,
|
||||||
displayname$: Observable<string>,
|
displayName$: Observable<string>,
|
||||||
) {
|
) {
|
||||||
this.participant$ = new BehaviorSubject(participant);
|
this.participant$ = new BehaviorSubject(participant);
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ class ScreenShare {
|
|||||||
this.participant$.asObservable(),
|
this.participant$.asObservable(),
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
displayname$.behavior(this.scope),
|
displayName$.behavior(this.scope),
|
||||||
participant.isLocal,
|
participant.isLocal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1271,14 +1271,14 @@ export class CallViewModel extends ViewModel {
|
|||||||
/**
|
/**
|
||||||
* Whether audio is currently being output through the earpiece.
|
* Whether audio is currently being output through the earpiece.
|
||||||
*/
|
*/
|
||||||
public readonly earpieceMode$: Observable<boolean> = combineLatest(
|
public readonly earpieceMode$: Behavior<boolean> = combineLatest(
|
||||||
[
|
[
|
||||||
this.mediaDevices.audioOutput.available$,
|
this.mediaDevices.audioOutput.available$,
|
||||||
this.mediaDevices.audioOutput.selected$,
|
this.mediaDevices.audioOutput.selected$,
|
||||||
],
|
],
|
||||||
(available, selected) =>
|
(available, selected) =>
|
||||||
selected !== undefined && available.get(selected.id)?.type === "earpiece",
|
selected !== undefined && available.get(selected.id)?.type === "earpiece",
|
||||||
).pipe(this.scope.state());
|
).behavior(this.scope);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Callback to toggle between the earpiece and the loudspeaker.
|
* Callback to toggle between the earpiece and the loudspeaker.
|
||||||
@@ -1286,7 +1286,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
* This will be `null` in case the target does not exist in the list
|
* This will be `null` in case the target does not exist in the list
|
||||||
* of available audio outputs.
|
* of available audio outputs.
|
||||||
*/
|
*/
|
||||||
public readonly audioOutputSwitcher$: Observable<{
|
public readonly audioOutputSwitcher$: Behavior<{
|
||||||
targetOutput: "earpiece" | "speaker";
|
targetOutput: "earpiece" | "speaker";
|
||||||
switch: () => void;
|
switch: () => void;
|
||||||
} | null> = combineLatest(
|
} | null> = combineLatest(
|
||||||
@@ -1298,7 +1298,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
const selectionType = selected && available.get(selected.id)?.type;
|
const selectionType = selected && available.get(selected.id)?.type;
|
||||||
|
|
||||||
// If we are in any output mode other than spaeker switch to speaker.
|
// If we are in any output mode other than spaeker switch to speaker.
|
||||||
const newSelectionType =
|
const newSelectionType: "earpiece" | "speaker" =
|
||||||
selectionType === "speaker" ? "earpiece" : "speaker";
|
selectionType === "speaker" ? "earpiece" : "speaker";
|
||||||
const newSelection = [...available].find(
|
const newSelection = [...available].find(
|
||||||
([, d]) => d.type === newSelectionType,
|
([, d]) => d.type === newSelectionType,
|
||||||
@@ -1311,7 +1311,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
switch: () => this.mediaDevices.audioOutput.select(id),
|
switch: () => this.mediaDevices.audioOutput.select(id),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
).behavior(this.scope);
|
||||||
|
|
||||||
public readonly reactions$ = this.reactionsSubject$
|
public readonly reactions$ = this.reactionsSubject$
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -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,36 +94,37 @@ 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 usingNames$
|
||||||
switchMap((withNames) =>
|
.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(),
|
startWith([]),
|
||||||
);
|
)
|
||||||
|
.behavior(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDeviceMap(
|
function buildDeviceMap(
|
||||||
@@ -161,42 +162,44 @@ 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.availableRaw$
|
||||||
map(buildDeviceMap),
|
.pipe(map(buildDeviceMap))
|
||||||
this.scope.state(),
|
.behavior(this.scope);
|
||||||
);
|
|
||||||
|
|
||||||
public readonly selected$ = selectDevice$(
|
public readonly selected$ = selectDevice$(
|
||||||
this.available$,
|
this.available$,
|
||||||
audioInputSetting.value$,
|
audioInputSetting.value$,
|
||||||
).pipe(
|
)
|
||||||
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((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||||
pairwise(),
|
map(
|
||||||
filter(([before, after]) => before !== after),
|
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
|
||||||
map(() => undefined),
|
),
|
||||||
),
|
pairwise(),
|
||||||
},
|
filter(([before, after]) => before !== after),
|
||||||
),
|
map(() => undefined),
|
||||||
this.scope.state(),
|
),
|
||||||
);
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.behavior(this.scope);
|
||||||
|
|
||||||
public select(id: string): void {
|
public select(id: string): void {
|
||||||
audioInputSetting.setValue(id);
|
audioInputSetting.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) => {
|
||||||
@@ -212,47 +215,48 @@ class AudioOutput
|
|||||||
"audiooutput",
|
"audiooutput",
|
||||||
this.usingNames$,
|
this.usingNames$,
|
||||||
this.scope,
|
this.scope,
|
||||||
).pipe(
|
)
|
||||||
map((availableRaw) => {
|
.pipe(
|
||||||
const available: Map<string, AudioOutputDeviceLabel> =
|
map((availableRaw) => {
|
||||||
buildDeviceMap(availableRaw);
|
const available: Map<string, AudioOutputDeviceLabel> =
|
||||||
// Create a virtual default audio output for browsers that don't have one.
|
buildDeviceMap(availableRaw);
|
||||||
// Its device ID must be the empty string because that's what setSinkId
|
// Create a virtual default audio output for browsers that don't have one.
|
||||||
// recognizes.
|
// Its device ID must be the empty string because that's what setSinkId
|
||||||
if (available.size && !available.has("") && !available.has("default"))
|
// recognizes.
|
||||||
available.set("", {
|
if (available.size && !available.has("") && !available.has("default"))
|
||||||
type: "default",
|
available.set("", {
|
||||||
name: availableRaw[0]?.label || null,
|
type: "default",
|
||||||
});
|
name: availableRaw[0]?.label || null,
|
||||||
// Note: creating virtual default input devices would be another problem
|
});
|
||||||
// entirely, because requesting a media stream from deviceId "" won't
|
// Note: creating virtual default input devices would be another problem
|
||||||
// automatically track the default device.
|
// entirely, because requesting a media stream from deviceId "" won't
|
||||||
return available;
|
// automatically track the default device.
|
||||||
}),
|
return available;
|
||||||
this.scope.state(),
|
}),
|
||||||
);
|
)
|
||||||
|
.behavior(this.scope);
|
||||||
|
|
||||||
public readonly selected$ = selectDevice$(
|
public readonly selected$ = selectDevice$(
|
||||||
this.available$,
|
this.available$,
|
||||||
audioOutputSetting.value$,
|
audioOutputSetting.value$,
|
||||||
).pipe(
|
)
|
||||||
map((id) =>
|
.pipe(
|
||||||
id === undefined
|
map((id) =>
|
||||||
? undefined
|
id === undefined
|
||||||
: {
|
? undefined
|
||||||
id,
|
: {
|
||||||
virtualEarpiece: false,
|
id,
|
||||||
},
|
virtualEarpiece: false,
|
||||||
),
|
},
|
||||||
this.scope.state(),
|
),
|
||||||
);
|
)
|
||||||
|
.behavior(this.scope);
|
||||||
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) => {
|
||||||
@@ -287,7 +291,7 @@ class ControlledAudioOutput
|
|||||||
|
|
||||||
return available;
|
return available;
|
||||||
},
|
},
|
||||||
).pipe(this.scope.state());
|
).behavior(this.scope);
|
||||||
|
|
||||||
private readonly deviceSelection$ = new Subject<string>();
|
private readonly deviceSelection$ = new Subject<string>();
|
||||||
|
|
||||||
@@ -309,7 +313,7 @@ class ControlledAudioOutput
|
|||||||
? undefined
|
? undefined
|
||||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||||
},
|
},
|
||||||
).pipe(this.scope.state());
|
).behavior(this.scope);
|
||||||
|
|
||||||
public constructor(private readonly scope: ObservableScope) {
|
public constructor(private readonly scope: ObservableScope) {
|
||||||
this.selected$.subscribe((device) => {
|
this.selected$.subscribe((device) => {
|
||||||
@@ -335,22 +339,21 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
|||||||
"videoinput",
|
"videoinput",
|
||||||
this.usingNames$,
|
this.usingNames$,
|
||||||
this.scope,
|
this.scope,
|
||||||
).pipe(map(buildDeviceMap));
|
)
|
||||||
|
.pipe(map(buildDeviceMap))
|
||||||
|
.behavior(this.scope);
|
||||||
public readonly selected$ = selectDevice$(
|
public readonly selected$ = selectDevice$(
|
||||||
this.available$,
|
this.available$,
|
||||||
videoInputSetting.value$,
|
videoInputSetting.value$,
|
||||||
).pipe(
|
)
|
||||||
map((id) => (id === undefined ? undefined : { id })),
|
.pipe(map((id) => (id === undefined ? undefined : { id })))
|
||||||
this.scope.state(),
|
.behavior(this.scope);
|
||||||
);
|
|
||||||
|
|
||||||
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
|
||||||
@@ -378,12 +381,12 @@ 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.deviceNamesRequest$
|
||||||
map(() => true),
|
.pipe(
|
||||||
startWith(false),
|
map(() => true),
|
||||||
this.scope.state(),
|
startWith(false),
|
||||||
);
|
)
|
||||||
|
.behavior(this.scope);
|
||||||
public readonly audioInput: MediaDevice<
|
public readonly audioInput: MediaDevice<
|
||||||
DeviceLabel,
|
DeviceLabel,
|
||||||
SelectedAudioInputDevice
|
SelectedAudioInputDevice
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ 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).behavior(
|
const audio$ = observeTrackReference$(participant$, audioSource).behavior(
|
||||||
@@ -390,7 +390,7 @@ 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$: Behavior<Date | null>,
|
public readonly handRaised$: Behavior<Date | null>,
|
||||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
@@ -402,7 +402,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
displayname$,
|
displayName$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media$ = participant$
|
const media$ = participant$
|
||||||
@@ -469,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
participant$: Observable<LocalParticipant | undefined>,
|
participant$: Observable<LocalParticipant | undefined>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom: LivekitRoom,
|
||||||
displayname$: Observable<string>,
|
displayName$: Behavior<string>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
@@ -479,7 +479,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
displayname$,
|
displayName$,
|
||||||
handRaised$,
|
handRaised$,
|
||||||
reaction$,
|
reaction$,
|
||||||
);
|
);
|
||||||
@@ -562,7 +562,7 @@ 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$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
@@ -627,7 +627,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(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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";
|
||||||
|
import "../state/Behavior"; // Patches in the Observable.behavior method
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This can transition into sth more complete: `GroupCallViewModel.ts`
|
* This can transition into sth more complete: `GroupCallViewModel.ts`
|
||||||
@@ -16,4 +18,4 @@ import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
|||||||
export const muteAllAudio$ = combineLatest(
|
export const muteAllAudio$ = combineLatest(
|
||||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||||
);
|
).behavior(globalScope);
|
||||||
|
|||||||
@@ -33,3 +33,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);
|
||||||
|
}
|
||||||
@@ -217,7 +217,7 @@ export async function withLocalMedia(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({ localParticipant }),
|
mockLivekitRoom({ localParticipant }),
|
||||||
of(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
);
|
);
|
||||||
@@ -256,7 +256,7 @@ 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"),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user