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:
Robin
2025-06-18 18:33:35 -04:00
parent 35ed313577
commit b3863748dc
26 changed files with 251 additions and 212 deletions

View File

@@ -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>;

View File

@@ -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),

View File

@@ -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(

View File

@@ -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

View File

@@ -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(

View File

@@ -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);

View File

@@ -33,3 +33,8 @@ export class ObservableScope {
this.ended$.complete();
}
}
/**
* The global scope, a scope which never ends.
*/
export const globalScope = new ObservableScope();

View File

@@ -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();
}