From bbd0f9f1fd470fd186f07d182e2030f3040a26d8 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 Aug 2024 17:28:59 -0400 Subject: [PATCH 1/7] Stop sharing state observables when the view model is destroyed By default, observables running with shareReplay will continue running forever even if there are no subscribers. We need to stop them when the view model is destroyed to avoid memory leaks and other unintuitive behavior. --- src/state/CallViewModel.ts | 42 ++++++++++++++---------------------- src/state/MediaViewModel.ts | 19 ++++++++-------- src/state/ObservableScope.ts | 28 +++++++++++++++++++++--- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index c68869f95..a700014f2 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -44,7 +44,6 @@ import { race, sample, scan, - shareReplay, skip, startWith, switchAll, @@ -194,11 +193,9 @@ class UserMedia { ), ), startWith(false), - distinctUntilChanged(), - this.scope.bind(), // Make this Observable hot so that the timers don't reset when you // resubscribe - shareReplay(1), + this.scope.state(), ); this.presenter = observeParticipantEvents( @@ -261,7 +258,7 @@ function findMatrixMember( export class CallViewModel extends ViewModel { private readonly rawRemoteParticipants = connectedParticipantsObserver( this.livekitRoom, - ).pipe(shareReplay(1)); + ).pipe(this.scope.state()); // Lists of participants to "hold" on display, even if LiveKit claims that // they've left @@ -383,7 +380,7 @@ export class CallViewModel extends ViewModel { finalizeValue((ts) => { for (const t of ts) t.destroy(); }), - shareReplay(1), + this.scope.state(), ); private readonly userMedia: Observable = this.mediaItems.pipe( @@ -402,7 +399,7 @@ export class CallViewModel extends ViewModel { map((mediaItems) => mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare), ), - shareReplay(1), + this.scope.state(), ); private readonly hasRemoteScreenShares: Observable = @@ -443,9 +440,8 @@ export class CallViewModel extends ViewModel { }, null, ), - distinctUntilChanged(), map((speaker) => speaker.vm), - shareReplay(1), + this.scope.state(), throttleTime(1600, undefined, { leading: true, trailing: true }), ); @@ -513,7 +509,7 @@ export class CallViewModel extends ViewModel { private readonly spotlight: Observable = this.spotlightAndPip.pipe( switchMap(([spotlight]) => spotlight), - shareReplay(1), + this.scope.state(), ); private readonly pip: Observable = @@ -538,15 +534,14 @@ export class CallViewModel extends ViewModel { if (width <= 600) return "narrow"; return "normal"; }), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); private readonly spotlightExpandedToggle = new Subject(); public readonly spotlightExpanded: Observable = this.spotlightExpandedToggle.pipe( accumulate(false, (expanded) => !expanded), - shareReplay(1), + this.scope.state(), ); private readonly gridModeUserSelection = new Subject(); @@ -572,8 +567,7 @@ export class CallViewModel extends ViewModel { ) ).pipe(startWith(userSelection ?? "grid")), ), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public setGridMode(value: GridMode): void { @@ -629,7 +623,7 @@ export class CallViewModel extends ViewModel { ); private readonly pipLayout: Observable = this.spotlight.pipe( - map((spotlight): Layout => ({ type: "pip", spotlight })), + map((spotlight) => ({ type: "pip", spotlight })), ); public readonly layout: Observable = this.windowMode.pipe( @@ -690,13 +684,12 @@ export class CallViewModel extends ViewModel { return this.pipLayout; } }), - shareReplay(1), + this.scope.state(), ); public showSpotlightIndicators: Observable = this.layout.pipe( map((l) => l.type !== "grid"), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); /** @@ -720,8 +713,7 @@ export class CallViewModel extends ViewModel { public showSpeakingIndicators: Observable = this.layout.pipe( map((l) => l.type !== "one-on-one" && !l.type.startsWith("spotlight-")), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public readonly toggleSpotlightExpanded: Observable<(() => void) | null> = @@ -741,7 +733,7 @@ export class CallViewModel extends ViewModel { map((enabled) => enabled ? (): void => this.spotlightExpandedToggle.next() : null, ), - shareReplay(1), + this.scope.state(), ); private readonly screenTap = new Subject(); @@ -771,8 +763,7 @@ export class CallViewModel extends ViewModel { public readonly showHeader: Observable = this.windowMode.pipe( map((mode) => mode !== "pip" && mode !== "flat"), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public readonly showFooter = this.windowMode.pipe( @@ -815,8 +806,7 @@ export class CallViewModel extends ViewModel { ); } }), - distinctUntilChanged(), - shareReplay(1), + this.scope.state(), ); public constructor( diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 197f0341a..ff262996f 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -36,12 +36,10 @@ import { BehaviorSubject, Observable, combineLatest, - distinctUntilChanged, distinctUntilKeyChanged, fromEvent, map, of, - shareReplay, startWith, switchMap, } from "rxjs"; @@ -84,7 +82,6 @@ function observeTrackReference( source, })), distinctUntilKeyChanged("publication"), - shareReplay(1), ); } @@ -119,15 +116,19 @@ abstract class BaseMediaViewModel extends ViewModel { videoSource: VideoSource, ) { super(); - const audio = observeTrackReference(participant, audioSource); - this.video = observeTrackReference(participant, videoSource); + 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) => callEncrypted && (a.publication?.isEncrypted === false || v.publication?.isEncrypted === false), - ).pipe(distinctUntilChanged(), shareReplay(1)); + ).pipe(this.scope.state()); } } @@ -151,7 +152,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { ParticipantEvent.IsSpeakingChanged, ).pipe( map((p) => p.isSpeaking), - shareReplay(1), + this.scope.state(), ); /** @@ -184,7 +185,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { Track.Source.Camera, ); - const media = observeParticipantMedia(participant).pipe(shareReplay(1)); + const media = observeParticipantMedia(participant).pipe(this.scope.state()); this.audioEnabled = media.pipe( map((m) => m.microphoneTrack?.isMuted === false), ); @@ -216,7 +217,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { map(() => facingModeFromLocalTrack(track).facingMode === "user"), ); }), - shareReplay(1), + this.scope.state(), ); /** diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index cb7cbd17d..813e064c0 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs"; +import { + distinctUntilChanged, + Observable, + shareReplay, + Subject, + takeUntil, +} from "rxjs"; + +type MonoTypeOperator = (o: Observable) => Observable; /** * A scope which limits the execution lifetime of its bound Observables. @@ -22,12 +30,26 @@ import { MonoTypeOperatorFunction, Subject, takeUntil } from "rxjs"; export class ObservableScope { private readonly ended = new Subject(); + private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended); + /** * Binds an Observable to this scope, so that it completes when the scope * ends. */ - public bind(): MonoTypeOperatorFunction { - return takeUntil(this.ended); + public bind(): MonoTypeOperator { + return this.bindImpl; + } + + private readonly stateImpl: MonoTypeOperator = (o) => + o.pipe(this.bind(), distinctUntilChanged(), shareReplay(1)); + + /** + * Transforms an Observable into a hot state Observable which replays its + * latest value upon subscription, skips updates with identical values, and + * is bound to this scope. + */ + public state(): MonoTypeOperator { + return this.stateImpl; } /** From f9c9517e598e50a4cc0bb6cdf07b88c7ba477c73 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 Aug 2024 17:32:38 -0400 Subject: [PATCH 2/7] Hydrate the call view model in a less hacky way This ensures that only a single view model is created per call, unlike the previous solution which would create extra view models in strict mode which it was unable to dispose of. The other way was invalid because React gives us no way to reliably dispose of a resource created in the render phase. This is essentially a memory leak fix. --- src/room/InCallView.tsx | 39 +++++++++++++++++++++++++++++--------- src/state/CallViewModel.ts | 34 --------------------------------- 2 files changed, 30 insertions(+), 43 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index fcd63fdad..43983fe30 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -69,7 +69,7 @@ import { InviteButton } from "../button/InviteButton"; import { LayoutToggle } from "./LayoutToggle"; import { ECConnectionState } from "../livekit/useECConnectionState"; import { useOpenIDSFU } from "../livekit/openIDSFU"; -import { GridMode, Layout, useCallViewModel } from "../state/CallViewModel"; +import { CallViewModel, GridMode, Layout } from "../state/CallViewModel"; import { Grid, TileProps } from "../grid/Grid"; import { useObservable } from "../state/useObservable"; import { useInitial } from "../useInitial"; @@ -105,6 +105,8 @@ export const ActiveCall: FC = (props) => { sfuConfig, props.e2eeSystem, ); + const connStateObservable = useObservable(connState); + const [vm, setVm] = useState(null); useEffect(() => { return (): void => { @@ -113,17 +115,41 @@ export const ActiveCall: FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - if (!livekitRoom) return null; + useEffect(() => { + if (livekitRoom !== undefined) { + const vm = new CallViewModel( + props.rtcSession.room, + livekitRoom, + props.e2eeSystem.kind !== E2eeType.NONE, + connStateObservable, + ); + setVm(vm); + return (): void => vm.destroy(); + } + }, [ + props.rtcSession.room, + livekitRoom, + props.e2eeSystem.kind, + connStateObservable, + ]); + + if (livekitRoom === undefined || vm === null) return null; return ( - + ); }; export interface InCallViewProps { client: MatrixClient; + vm: CallViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; livekitRoom: Room; @@ -138,6 +164,7 @@ export interface InCallViewProps { export const InCallView: FC = ({ client, + vm, matrixInfo, rtcSession, livekitRoom, @@ -193,12 +220,6 @@ export const InCallView: FC = ({ const reducedControls = boundsValid && bounds.width <= 340; const noControls = reducedControls && bounds.height <= 400; - const vm = useCallViewModel( - rtcSession.room, - livekitRoom, - matrixInfo.e2eeSystem.kind !== E2eeType.NONE, - connState, - ); const windowMode = useObservableEagerState(vm.windowMode); const layout = useObservableEagerState(vm.layout); const gridMode = useObservableEagerState(vm.gridMode); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index a700014f2..a4633c5c2 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -26,7 +26,6 @@ import { RemoteParticipant, } from "livekit-client"; import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix"; -import { useEffect, useRef } from "react"; import { EMPTY, Observable, @@ -57,12 +56,10 @@ import { import { logger } from "matrix-js-sdk/src/logger"; import { ViewModel } from "./ViewModel"; -import { useObservable } from "./useObservable"; import { ECAddonConnectionState, ECConnectionState, } from "../livekit/useECConnectionState"; -import { usePrevious } from "../usePrevious"; import { LocalUserMediaViewModel, MediaViewModel, @@ -819,34 +816,3 @@ export class CallViewModel extends ViewModel { super(); } } - -export function useCallViewModel( - matrixRoom: MatrixRoom, - livekitRoom: LivekitRoom, - encrypted: boolean, - connectionState: ECConnectionState, -): CallViewModel { - const prevMatrixRoom = usePrevious(matrixRoom); - const prevLivekitRoom = usePrevious(livekitRoom); - const prevEncrypted = usePrevious(encrypted); - const connectionStateObservable = useObservable(connectionState); - - const vm = useRef(); - if ( - matrixRoom !== prevMatrixRoom || - livekitRoom !== prevLivekitRoom || - encrypted !== prevEncrypted - ) { - vm.current?.destroy(); - vm.current = new CallViewModel( - matrixRoom, - livekitRoom, - encrypted, - connectionStateObservable, - ); - } - - useEffect(() => vm.current?.destroy(), []); - - return vm.current!; -} From 5a6f9a025118cbaf76bb317146a8e65d76f3249e Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 Aug 2024 17:36:11 -0400 Subject: [PATCH 3/7] Add simple global controls to put the call in picture-in-picture mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our web and mobile apps (will) all support putting calls into a picture-in-picture mode. However, it'd be nice to have a way of doing this that's more explicit than a breakpoint, because PiP views could in theory get fairly large. Specifically, on mobile, we want a way to do this that can tell you whether the call is ongoing, and that works even without the widget API (because we support SPA calls in the Element X apps…) To this end, I've created a simple global "controls" API on the window. Right now it only has methods for controlling the picture-in-picture state, but in theory we can expand it to also control mute states, which is current possible via the widget API only. --- docs/README.md | 3 ++- docs/controls.md | 6 ++++++ src/@types/global.d.ts | 2 ++ src/controls.ts | 36 ++++++++++++++++++++++++++++++++++++ src/state/CallViewModel.ts | 18 ++++++++++++++---- 5 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 docs/controls.md create mode 100644 src/controls.ts diff --git a/docs/README.md b/docs/README.md index a78c7253b..113b52c5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,5 +2,6 @@ This folder contains documentation for Element Call setup and usage. -- [Url format and parameters](./url-params.md) - [Embedded vs standalone mode](./embedded-standalone.md) +- [Url format and parameters](./url-params.md) +- [Global JS controls](./controls.md) diff --git a/docs/controls.md b/docs/controls.md new file mode 100644 index 000000000..f1054e5b7 --- /dev/null +++ b/docs/controls.md @@ -0,0 +1,6 @@ +# Global JS controls + +A few aspects of Element Call's interface can be controlled through a global API on the `window`: + +- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call. +- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call. diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index f27c273e5..63612af16 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,6 +15,7 @@ limitations under the License. */ import "matrix-js-sdk/src/@types/global"; +import { Controls } from "../controls"; declare global { interface Document { @@ -26,6 +27,7 @@ declare global { interface Window { // TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10 OLM_OPTIONS: Record; + controls: Controls; } interface HTMLElement { diff --git a/src/controls.ts b/src/controls.ts new file mode 100644 index 000000000..90cca3cf3 --- /dev/null +++ b/src/controls.ts @@ -0,0 +1,36 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Subject } from "rxjs"; + +export interface Controls { + enablePip: () => void; + disablePip: () => void; +} + +export const pipEnable = new Subject(); +export const pipDisable = new Subject(); + +window.controls = { + enablePip(): void { + if (!pipEnable.observed) throw new Error("No call is running"); + pipEnable.next(); + }, + disablePip(): void { + if (!pipDisable.observed) throw new Error("No call is running"); + pipDisable.next(); + }, +}; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index a4633c5c2..603a0444b 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -71,6 +71,7 @@ import { accumulate, finalizeValue } from "../observable-utils"; import { ObservableScope } from "./ObservableScope"; import { duplicateTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; +import { pipDisable, pipEnable } from "../controls"; // How long we wait after a focus switch before showing the real participant // list again @@ -512,10 +513,12 @@ export class CallViewModel extends ViewModel { private readonly pip: Observable = this.spotlightAndPip.pipe(switchMap(([, pip]) => pip)); - /** - * The general shape of the window. - */ - public readonly windowMode: Observable = fromEvent( + private readonly pipEnabled: Observable = merge( + pipEnable.pipe(map(() => true)), + pipDisable.pipe(map(() => false)), + ).pipe(startWith(false)); + + private readonly naturalWindowMode: Observable = fromEvent( window, "resize", ).pipe( @@ -534,6 +537,13 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); + /** + * The general shape of the window. + */ + public readonly windowMode: Observable = this.pipEnabled.pipe( + switchMap((pip) => (pip ? of("pip") : this.naturalWindowMode)), + ); + private readonly spotlightExpandedToggle = new Subject(); public readonly spotlightExpanded: Observable = this.spotlightExpandedToggle.pipe( From 3f71f78d45b3154097ca048943e7b2cce7b36124 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 Aug 2024 17:43:53 -0400 Subject: [PATCH 4/7] Fix footer appearing in large PiP views --- src/room/InCallView.module.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 32d34fb7c..cfc436c91 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -58,6 +58,10 @@ limitations under the License. ); } +.footer.hidden { + display: none; +} + .footer.overlay { position: absolute; inset-block-end: 0; @@ -67,6 +71,7 @@ limitations under the License. } .footer.overlay.hidden { + display: grid; opacity: 0; pointer-events: none; } From 4dc258f3ef1885066c068c61dcd8142b5946d064 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 21 Aug 2024 10:23:04 -0400 Subject: [PATCH 5/7] Add a method for whether you can enter picture-in-picture mode --- docs/controls.md | 1 + src/controls.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/docs/controls.md b/docs/controls.md index f1054e5b7..02df61efd 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -2,5 +2,6 @@ A few aspects of Element Call's interface can be controlled through a global API on the `window`: +- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode. - `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call. - `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call. diff --git a/src/controls.ts b/src/controls.ts index 90cca3cf3..0cc33e5bb 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -17,6 +17,7 @@ limitations under the License. import { Subject } from "rxjs"; export interface Controls { + canEnterPip: () => boolean; enablePip: () => void; disablePip: () => void; } @@ -25,6 +26,9 @@ export const pipEnable = new Subject(); export const pipDisable = new Subject(); window.controls = { + canEnterPip(): boolean { + return pipEnable.observed; + }, enablePip(): void { if (!pipEnable.observed) throw new Error("No call is running"); pipEnable.next(); From 60fb801deb68a0c90c2d52cc16f6b37864b44f4f Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 23 Aug 2024 13:02:16 -0400 Subject: [PATCH 6/7] Fix type error --- src/room/InCallView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 43983fe30..22b6be8cf 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -93,7 +93,7 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const maxTapDurationMs = 400; export interface ActiveCallProps - extends Omit { + extends Omit { e2eeSystem: EncryptionSystem; } From a4460d276f5e3891eff05d260ecb41e5a618cae3 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 23 Aug 2024 13:07:35 -0400 Subject: [PATCH 7/7] Have the controls emit booleans directly --- src/controls.ts | 13 ++++++------- src/state/CallViewModel.ts | 9 ++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index 0cc33e5bb..3f6ecc545 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -22,19 +22,18 @@ export interface Controls { disablePip: () => void; } -export const pipEnable = new Subject(); -export const pipDisable = new Subject(); +export const setPipEnabled = new Subject(); window.controls = { canEnterPip(): boolean { - return pipEnable.observed; + return setPipEnabled.observed; }, enablePip(): void { - if (!pipEnable.observed) throw new Error("No call is running"); - pipEnable.next(); + if (!setPipEnabled.observed) throw new Error("No call is running"); + setPipEnabled.next(true); }, disablePip(): void { - if (!pipDisable.observed) throw new Error("No call is running"); - pipDisable.next(); + if (!setPipEnabled.observed) throw new Error("No call is running"); + setPipEnabled.next(false); }, }; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 603a0444b..0ba14845f 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -71,7 +71,7 @@ import { accumulate, finalizeValue } from "../observable-utils"; import { ObservableScope } from "./ObservableScope"; import { duplicateTiles } from "../settings/settings"; import { isFirefox } from "../Platform"; -import { pipDisable, pipEnable } from "../controls"; +import { setPipEnabled } from "../controls"; // How long we wait after a focus switch before showing the real participant // list again @@ -513,10 +513,9 @@ export class CallViewModel extends ViewModel { private readonly pip: Observable = this.spotlightAndPip.pipe(switchMap(([, pip]) => pip)); - private readonly pipEnabled: Observable = merge( - pipEnable.pipe(map(() => true)), - pipDisable.pipe(map(() => false)), - ).pipe(startWith(false)); + private readonly pipEnabled: Observable = setPipEnabled.pipe( + startWith(false), + ); private readonly naturalWindowMode: Observable = fromEvent( window,