mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-23 05:07:03 +00:00
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:
@@ -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 <T>(
|
||||
): Behavior<T> {
|
||||
const subject$ = new BehaviorSubject<T | typeof nothing>(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<T>;
|
||||
|
||||
@@ -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<Layout>): Observable<LayoutSummary> {
|
||||
case "grid":
|
||||
return combineLatest(
|
||||
[
|
||||
l.spotlight?.media$ ?? of(undefined),
|
||||
l.spotlight?.media$ ?? constant(undefined),
|
||||
...l.grid.map((vm) => vm.media$),
|
||||
],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, ...grid) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight?.map((vm) => vm.id),
|
||||
@@ -178,7 +180,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
case "spotlight-expanded":
|
||||
return combineLatest(
|
||||
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
|
||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
||||
(spotlight, pip) => ({
|
||||
type: l.type,
|
||||
spotlight: spotlight.map((vm) => vm.id),
|
||||
|
||||
@@ -339,7 +339,7 @@ class ScreenShare {
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
liveKitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayName$: Observable<string>,
|
||||
) {
|
||||
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<boolean> = combineLatest(
|
||||
public readonly earpieceMode$: Behavior<boolean> = 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(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
@@ -34,6 +33,7 @@ import {
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
@@ -74,11 +74,11 @@ export interface MediaDevice<Label, Selected> {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available$: Observable<Map<string, Label>>;
|
||||
available$: Behavior<Map<string, Label>>;
|
||||
/**
|
||||
* The selected device.
|
||||
*/
|
||||
selected$: Observable<Selected | undefined>;
|
||||
selected$: Behavior<Selected | undefined>;
|
||||
/**
|
||||
* Selects a new device.
|
||||
*/
|
||||
@@ -94,36 +94,37 @@ export interface MediaDevice<Label, Selected> {
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ =
|
||||
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
|
||||
function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Observable<boolean>,
|
||||
usingNames$: Behavior<boolean>,
|
||||
scope: ObservableScope,
|
||||
): Observable<MediaDeviceInfo[]> {
|
||||
): Behavior<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
const devices$ = createMediaDeviceObserver(kind, logError, false);
|
||||
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
|
||||
|
||||
return usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
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$<Label>(
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
|
||||
public readonly available$ = this.availableRaw$.pipe(
|
||||
map(buildDeviceMap),
|
||||
this.scope.state(),
|
||||
);
|
||||
public readonly available$ = this.availableRaw$
|
||||
.pipe(map(buildDeviceMap))
|
||||
.behavior(this.scope);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
)
|
||||
.pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map(
|
||||
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
|
||||
),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
.behavior(this.scope);
|
||||
|
||||
public select(id: string): void {
|
||||
audioInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
@@ -212,47 +215,48 @@ class AudioOutput
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
)
|
||||
.pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
)
|
||||
.behavior(this.scope);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioOutputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
)
|
||||
.pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
)
|
||||
.behavior(this.scope);
|
||||
public select(id: string): void {
|
||||
audioOutputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
@@ -287,7 +291,7 @@ class ControlledAudioOutput
|
||||
|
||||
return available;
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
).behavior(this.scope);
|
||||
|
||||
private readonly deviceSelection$ = new Subject<string>();
|
||||
|
||||
@@ -309,7 +313,7 @@ class ControlledAudioOutput
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
).behavior(this.scope);
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {
|
||||
this.selected$.subscribe((device) => {
|
||||
@@ -335,22 +339,21 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(map(buildDeviceMap));
|
||||
|
||||
)
|
||||
.pipe(map(buildDeviceMap))
|
||||
.behavior(this.scope);
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
videoInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
)
|
||||
.pipe(map((id) => (id === undefined ? undefined : { id })))
|
||||
.behavior(this.scope);
|
||||
public select(id: string): void {
|
||||
videoInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly usingNames$: Behavior<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
@@ -378,12 +381,12 @@ export class MediaDevices {
|
||||
// you to do to receive device names in lieu of a more explicit permissions
|
||||
// API. This flag never resets to false, because once permissions are granted
|
||||
// the first time, the user won't be prompted again until reload of the page.
|
||||
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
private readonly usingNames$ = this.deviceNamesRequest$
|
||||
.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
)
|
||||
.behavior(this.scope);
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
|
||||
@@ -258,7 +258,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom: LivekitRoom,
|
||||
public readonly displayname$: Observable<string>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
) {
|
||||
super();
|
||||
const audio$ = observeTrackReference$(participant$, audioSource).behavior(
|
||||
@@ -390,7 +390,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayName$: Behavior<string>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
@@ -402,7 +402,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
);
|
||||
|
||||
const media$ = participant$
|
||||
@@ -469,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayName$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
@@ -479,7 +479,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
displayname$,
|
||||
displayName$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
@@ -562,7 +562,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
participant$: Observable<RemoteParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayname$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
@@ -627,7 +627,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
displayname$: Observable<string>,
|
||||
displayname$: Behavior<string>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
|
||||
@@ -9,6 +9,8 @@ import { combineLatest, startWith } from "rxjs";
|
||||
|
||||
import { setAudioEnabled$ } from "../controls";
|
||||
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`
|
||||
@@ -16,4 +18,4 @@ import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
|
||||
export const muteAllAudio$ = combineLatest(
|
||||
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
|
||||
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
|
||||
);
|
||||
).behavior(globalScope);
|
||||
|
||||
@@ -33,3 +33,8 @@ export class ObservableScope {
|
||||
this.ended$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The global scope, a scope which never ends.
|
||||
*/
|
||||
export const globalScope = new ObservableScope();
|
||||
|
||||
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
@@ -18,15 +17,15 @@ function createId(): string {
|
||||
export class GridTileViewModel extends ViewModel {
|
||||
public readonly id = createId();
|
||||
|
||||
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
|
||||
public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class SpotlightTileViewModel extends ViewModel {
|
||||
public constructor(
|
||||
public readonly media$: Observable<MediaViewModel[]>,
|
||||
public readonly maximised$: Observable<boolean>,
|
||||
public readonly media$: Behavior<MediaViewModel[]>,
|
||||
public readonly maximised$: Behavior<boolean>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user