diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index f1a6f0aa..69673293 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -24,8 +24,6 @@ import { import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import classNames from "classnames"; -import { useObservableState } from "observable-hooks"; -import { map } from "rxjs"; import { useReactionsSender } from "../reactions/useReactionsSender"; import styles from "./ReactionToggleButton.module.css"; @@ -36,6 +34,7 @@ import { } from "../reactions"; import { Modal } from "../Modal"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -180,12 +179,8 @@ export function ReactionToggleButton({ const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = useObservableState( - vm.handsRaised$.pipe(map((v) => !!v[identifier])), - ); - const canReact = useObservableState( - vm.reactions$.pipe(map((v) => !v[identifier])), - ); + const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier]; + const canReact = !useBehavior(vm.reactions$)[identifier]; useEffect(() => { // Clear whenever the reactions menu state changes. diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index d0588fb6..6c85b8af 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -24,16 +24,16 @@ import { createContext, memo, use, + useCallback, useEffect, useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import useMeasure from "react-use-measure"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/lib/logger"; -import { useObservableEagerState } from "observable-hooks"; -import { fromEvent, map, startWith } from "rxjs"; import styles from "./Grid.module.css"; import { useMergedRefs } from "../useMergedRefs"; @@ -155,11 +155,6 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void { ); } -const windowHeightObservable$ = fromEvent(window, "resize").pipe( - startWith(null), - map(() => window.innerHeight), -); - export interface LayoutProps { ref?: Ref; model: LayoutModel; @@ -261,7 +256,13 @@ export function Grid< const [gridRoot, gridRef2] = useState(null); const gridRef = useMergedRefs(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(null); const [generation, setGeneration] = useState(null); const [visibleTilesCallback, setVisibleTilesCallback] = diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLayout.tsx index 8e1bffbe..675e4d0a 100644 --- a/src/grid/OneOnOneLayout.tsx +++ b/src/grid/OneOnOneLayout.tsx @@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod import { type CallLayout, arrangeTiles } from "./CallLayout"; import styles from "./OneOnOneLayout.module.css"; import { type DragCallback, useUpdateLayout } from "./Grid"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "one-on-one" layout, in which the remote participant @@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout = ({ scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { useUpdateLayout(); const { width, height } = useObservableEagerState(minBounds$); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const { tileWidth, tileHeight } = useMemo( () => arrangeTiles(width, height, 1), [width, height], diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index 88271752..9dd2a109 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode, useCallback } from "react"; -import { useObservableEagerState } from "observable-hooks"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type CallLayout } from "./CallLayout"; import { type DragCallback, useUpdateLayout } from "./Grid"; import styles from "./SpotlightExpandedLayout.module.css"; +import { useBehavior } from "../useBehavior"; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - const pipAlignmentValue = useObservableEagerState(pipAlignment$); + const pipAlignmentValue = useBehavior(pipAlignment$); const onDragPip: DragCallback = useCallback( ({ xRatio, yRatio }) => diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 3b4de6a1..ad11ed11 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import styles from "./SpotlightPortraitLayout.module.css"; import { useUpdateLayout, useVisibleTiles } from "./Grid"; +import { useBehavior } from "../useBehavior"; interface GridCSSProperties extends CSSProperties { "--grid-gap": string; @@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout< width, model.grid.length, ); - const withIndicators = - useObservableEagerState(model.spotlight.media$).length > 1; + const withIndicators = useBehavior(model.spotlight.media$).length > 1; return (
myMembershipIdentifier !== undefined @@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({ [myMembershipIdentifier, reactions], ); - const handsRaised = useObservableEagerState(vm.handsRaised$); + const handsRaised = useBehavior(vm.handsRaised$); const myRaisedHand = useMemo( () => myMembershipIdentifier !== undefined diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 5b9b1f02..4af599bb 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -24,7 +24,6 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import { useObservableEagerState } from "observable-hooks"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { @@ -72,6 +71,7 @@ import { import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; declare global { interface Window { @@ -110,7 +110,7 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const muteAllAudio = useObservableEagerState(muteAllAudio$); + const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( useAudioContext({ sounds: callEventAudioSounds, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 452e8572..74b738d9 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -25,7 +25,7 @@ import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; import { BehaviorSubject, map } from "rxjs"; -import { useObservable, useObservableEagerState } from "observable-hooks"; +import { useObservable } from "observable-hooks"; import { logger } from "matrix-js-sdk/lib/logger"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { @@ -112,6 +112,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership import { useMediaDevices } from "../MediaDevicesContext.ts"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; +import { useBehavior } from "../useBehavior.ts"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -251,7 +252,7 @@ export const InCallView: FC = ({ room: livekitRoom, }); - const muteAllAudio = useObservableEagerState(muteAllAudio$); + const muteAllAudio = useBehavior(muteAllAudio$); // This seems like it might be enough logic to use move it into the call view model? const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); @@ -302,15 +303,15 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const windowMode = useObservableEagerState(vm.windowMode$); - const layout = useObservableEagerState(vm.layout$); - const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); + const windowMode = useBehavior(vm.windowMode$); + const layout = useBehavior(vm.layout$); + const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useObservableEagerState(vm.gridMode$); - const showHeader = useObservableEagerState(vm.showHeader$); - const showFooter = useObservableEagerState(vm.showFooter$); - const earpieceMode = useObservableEagerState(vm.earpieceMode$); - const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); + const gridMode = useBehavior(vm.gridMode$); + const showHeader = useBehavior(vm.showHeader$); + const showFooter = useBehavior(vm.showFooter$); + const earpieceMode = useBehavior(vm.earpieceMode$); + const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const switchCamera = useSwitchCamera(vm.localVideo$); // Ideally we could detect taps by listening for click events and checking @@ -527,16 +528,12 @@ export const InCallView: FC = ({ targetHeight, model, }: TileProps): ReactNode { - const spotlightExpanded = useObservableEagerState( - vm.spotlightExpanded$, - ); - const onToggleExpanded = useObservableEagerState( - vm.toggleSpotlightExpanded$, - ); - const showSpeakingIndicatorsValue = useObservableEagerState( + const spotlightExpanded = useBehavior(vm.spotlightExpanded$); + const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); + const showSpeakingIndicatorsValue = useBehavior( vm.showSpeakingIndicators$, ); - const showSpotlightIndicatorsValue = useObservableEagerState( + const showSpotlightIndicatorsValue = useBehavior( vm.showSpotlightIndicators$, ); diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 9bf7ab66..f3dff848 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { type ReactNode } from "react"; -import { useObservableState } from "observable-hooks"; import styles from "./ReactionsOverlay.module.css"; import { type CallViewModel } from "../state/CallViewModel"; +import { useBehavior } from "../useBehavior"; export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { - const reactionsIcons = useObservableState(vm.visibleReactions$); + const reactionsIcons = useBehavior(vm.visibleReactions$); return (
- {reactionsIcons?.map(({ sender, emoji, startX }) => ( + {reactionsIcons.map(({ sender, emoji, startX }) => ( = ({ // rather than the input section. const { controlledAudioDevices } = useUrlParams(); // If we are on iOS we will show a button to open the native audio device picker. - const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$); + const iosDeviceMenu = useBehavior(iosDeviceMenu$); const audioTab: Tab = { key: "audio", diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 50e70671..7c7f1250 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details. */ import { logger } from "matrix-js-sdk/lib/logger"; -import { BehaviorSubject, type Observable } from "rxjs"; -import { useObservableEagerState } from "observable-hooks"; +import { BehaviorSubject } from "rxjs"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; +import { type Behavior } from "../state/Behavior"; +import { useBehavior } from "../useBehavior"; export class Setting { public constructor( @@ -38,7 +39,7 @@ export class Setting { private readonly key: string; private readonly _value$: BehaviorSubject; - public readonly value$: Observable; + public readonly value$: Behavior; public readonly setValue = (value: T): void => { this._value$.next(value); @@ -53,7 +54,7 @@ export class Setting { * React hook that returns a settings's current value and a setter. */ export function useSetting(setting: Setting): [T, (value: T) => void] { - return [useObservableEagerState(setting.value$), setting.setValue]; + return [useBehavior(setting.value$), setting.setValue]; } // null = undecided diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts index 8b2ce9a5..4ae651f2 100644 --- a/src/state/Behavior.ts +++ b/src/state/Behavior.ts @@ -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. */ -import { BehaviorSubject, Observable } from "rxjs"; +import { BehaviorSubject, distinctUntilChanged, Observable } from "rxjs"; import { type ObservableScope } from "./ObservableScope"; @@ -45,7 +45,7 @@ Observable.prototype.behavior = function ( ): Behavior { const subject$ = new BehaviorSubject(nothing); // 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) throw new Error("Behavior failed to synchronously emit an initial value"); return subject$ as Behavior; diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 42b04079..2ad254f2 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -75,6 +75,7 @@ import { import { ObservableScope } from "./ObservableScope"; import { MediaDevices } from "./MediaDevices"; import { getValue } from "../utils/observable"; +import { constant } from "./Behavior"; const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); @@ -157,9 +158,10 @@ function summarizeLayout$(l$: Observable): Observable { case "grid": return combineLatest( [ - l.spotlight?.media$ ?? of(undefined), + l.spotlight?.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], + // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight?.map((vm) => vm.id), @@ -178,7 +180,8 @@ function summarizeLayout$(l$: Observable): Observable { ); case "spotlight-expanded": return combineLatest( - [l.spotlight.media$, l.pip?.media$ ?? of(undefined)], + [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], + // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, pip) => ({ type: l.type, spotlight: spotlight.map((vm) => vm.id), diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index a9bf5413..20105a85 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -339,7 +339,7 @@ class ScreenShare { participant: LocalParticipant | RemoteParticipant, encryptionSystem: EncryptionSystem, liveKitRoom: LivekitRoom, - displayname$: Observable, + displayName$: Observable, ) { this.participant$ = new BehaviorSubject(participant); @@ -349,7 +349,7 @@ class ScreenShare { this.participant$.asObservable(), encryptionSystem, liveKitRoom, - displayname$.behavior(this.scope), + displayName$.behavior(this.scope), participant.isLocal, ); } @@ -1271,14 +1271,14 @@ export class CallViewModel extends ViewModel { /** * Whether audio is currently being output through the earpiece. */ - public readonly earpieceMode$: Observable = combineLatest( + public readonly earpieceMode$: Behavior = combineLatest( [ this.mediaDevices.audioOutput.available$, this.mediaDevices.audioOutput.selected$, ], (available, selected) => selected !== undefined && available.get(selected.id)?.type === "earpiece", - ).pipe(this.scope.state()); + ).behavior(this.scope); /** * 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 * of available audio outputs. */ - public readonly audioOutputSwitcher$: Observable<{ + public readonly audioOutputSwitcher$: Behavior<{ targetOutput: "earpiece" | "speaker"; switch: () => void; } | null> = combineLatest( @@ -1298,7 +1298,7 @@ export class CallViewModel extends ViewModel { const selectionType = selected && available.get(selected.id)?.type; // If we are in any output mode other than spaeker switch to speaker. - const newSelectionType = + const newSelectionType: "earpiece" | "speaker" = selectionType === "speaker" ? "earpiece" : "speaker"; const newSelection = [...available].find( ([, d]) => d.type === newSelectionType, @@ -1311,7 +1311,7 @@ export class CallViewModel extends ViewModel { switch: () => this.mediaDevices.audioOutput.select(id), }; }, - ); + ).behavior(this.scope); public readonly reactions$ = this.reactionsSubject$ .pipe( diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index f251e759..b27120b5 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -10,7 +10,6 @@ import { filter, map, merge, - of, pairwise, startWith, Subject, @@ -34,6 +33,7 @@ import { import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; +import { type Behavior, constant } from "./Behavior"; // This hardcoded id is used in EX ios! It can only be changed in coordination with // the ios swift team. @@ -74,11 +74,11 @@ export interface MediaDevice { /** * A map from available device IDs to labels. */ - available$: Observable>; + available$: Behavior>; /** * The selected device. */ - selected$: Observable; + selected$: Behavior; /** * Selects a new device. */ @@ -94,36 +94,37 @@ export interface MediaDevice { * `availableOutputDevices$.includes((d)=>d.forEarpiece)` */ export const iosDeviceMenu$ = - platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$; + platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$; function availableRawDevices$( kind: MediaDeviceKind, - usingNames$: Observable, + usingNames$: Behavior, scope: ObservableScope, -): Observable { +): Behavior { const logError = (e: Error): void => logger.error("Error creating MediaDeviceObserver", e); const devices$ = createMediaDeviceObserver(kind, logError, false); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); - return usingNames$.pipe( - switchMap((withNames) => - withNames - ? // It might be that there is already a media stream running somewhere, - // and so we can do without requesting a second one. Only switch to the - // device observer that explicitly requests the names if we see that - // names are in fact missing from the initial device enumeration. - devices$.pipe( - switchWhen( - (devices, i) => i === 0 && devices.every((d) => !d.label), - devicesWithNames$, - ), - ) - : devices$, - ), - startWith([]), - scope.state(), - ); + return usingNames$ + .pipe( + switchMap((withNames) => + withNames + ? // It might be that there is already a media stream running somewhere, + // and so we can do without requesting a second one. Only switch to the + // device observer that explicitly requests the names if we see that + // names are in fact missing from the initial device enumeration. + devices$.pipe( + switchWhen( + (devices, i) => i === 0 && devices.every((d) => !d.label), + devicesWithNames$, + ), + ) + : devices$, + ), + startWith([]), + ) + .behavior(scope); } function buildDeviceMap( @@ -161,42 +162,44 @@ function selectDevice$