mirror of
https://github.com/vector-im/element-call.git
synced 2026-06-27 17:52:56 +00:00
feat(mute): add syncing state and disable toggle during async mute
This commit is contained in:
@@ -92,6 +92,75 @@ describe("MuteState", () => {
|
||||
await flushPromises();
|
||||
expect(lastEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("should ignore toggle while syncing but still process set requests", async () => {
|
||||
const deviceStub = {
|
||||
available$: constant(
|
||||
new Map<string, DeviceLabel>([
|
||||
["mic", { type: "name", name: "Microphone" }],
|
||||
]),
|
||||
),
|
||||
selected$: constant({ id: "mic" }),
|
||||
select(): void {},
|
||||
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
|
||||
|
||||
const muteState = new MuteState(
|
||||
testScope,
|
||||
deviceStub,
|
||||
false,
|
||||
constant(false),
|
||||
);
|
||||
|
||||
const first = Promise.withResolvers<boolean>();
|
||||
const second = Promise.withResolvers<boolean>();
|
||||
const handler = vi
|
||||
.fn<(desired: boolean) => Promise<boolean>>()
|
||||
.mockImplementationOnce(async () => first.promise)
|
||||
.mockImplementationOnce(async () => second.promise);
|
||||
muteState.setHandler(handler);
|
||||
|
||||
const syncingValues: boolean[] = [];
|
||||
muteState.syncing$.subscribe((syncing) => {
|
||||
syncingValues.push(syncing);
|
||||
});
|
||||
|
||||
let setEnabled: ((enabled: boolean) => void) | null = null;
|
||||
muteState.setEnabled$.subscribe((setter) => {
|
||||
setEnabled = setter;
|
||||
});
|
||||
let toggle: (() => void) | null = null;
|
||||
muteState.toggle$.subscribe((toggleFn) => {
|
||||
toggle = toggleFn;
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Start syncing by requesting unmute.
|
||||
toggle!();
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenNthCalledWith(1, true);
|
||||
|
||||
// Toggle requests are ignored while syncing.
|
||||
toggle!();
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// setEnabled still updates latest desired state while syncing (push-to-talk).
|
||||
setEnabled!(false);
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.resolve(true);
|
||||
await flushPromises();
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).toHaveBeenNthCalledWith(2, false);
|
||||
|
||||
second.resolve(false);
|
||||
await flushPromises();
|
||||
expect(syncingValues).toContain(true);
|
||||
expect(syncingValues.at(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MuteStates", () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
interface MuteStateData {
|
||||
enabled$: Observable<boolean>;
|
||||
syncing$: Observable<boolean>;
|
||||
set: ((enabled: boolean) => void) | null;
|
||||
toggle: (() => void) | null;
|
||||
}
|
||||
@@ -79,33 +80,40 @@ export class MuteState<Label, Selected> {
|
||||
this.handler$.value(false).catch((err) => {
|
||||
logger.error("MuteState-disable: handler error", err);
|
||||
});
|
||||
return { enabled$: of(false), set: null, toggle: null };
|
||||
return {
|
||||
enabled$: of(false),
|
||||
syncing$: of(false),
|
||||
set: null,
|
||||
toggle: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Assume the default value only once devices are actually connected
|
||||
let enabled = this.enabledByDefault;
|
||||
const set$ = new Subject<boolean>();
|
||||
const toggle$ = new Subject<void>();
|
||||
const syncing$ = new BehaviorSubject(false);
|
||||
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
|
||||
const enabled$ = new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(enabled);
|
||||
let latestDesired = this.enabledByDefault;
|
||||
let syncing = false;
|
||||
|
||||
const sync = async (): Promise<void> => {
|
||||
if (enabled === latestDesired) syncing = false;
|
||||
else {
|
||||
if (enabled === latestDesired) {
|
||||
syncing$.next(false);
|
||||
} else {
|
||||
const previouslyEnabled = enabled;
|
||||
syncing$.next(true);
|
||||
enabled = await firstValueFrom(
|
||||
this.handler$.pipe(
|
||||
switchMap(async (handler) => handler(latestDesired)),
|
||||
),
|
||||
);
|
||||
if (enabled === previouslyEnabled) {
|
||||
syncing = false;
|
||||
syncing$.next(false);
|
||||
} else {
|
||||
subscriber.next(enabled);
|
||||
syncing = true;
|
||||
syncing$.next(true);
|
||||
sync().catch((err) => {
|
||||
// TODO: better error handling
|
||||
logger.error("MuteState: handler error", err);
|
||||
@@ -116,21 +124,28 @@ export class MuteState<Label, Selected> {
|
||||
|
||||
const s = desired$.subscribe((desired) => {
|
||||
latestDesired = desired;
|
||||
if (syncing === false) {
|
||||
syncing = true;
|
||||
if (syncing$.value === false) {
|
||||
syncing$.next(true);
|
||||
sync().catch((err) => {
|
||||
// TODO: better error handling
|
||||
logger.error("MuteState: handler error", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
return (): void => s.unsubscribe();
|
||||
return (): void => {
|
||||
s.unsubscribe();
|
||||
syncing$.complete();
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
set: (enabled: boolean): void => set$.next(enabled),
|
||||
toggle: (): void => toggle$.next(),
|
||||
toggle: (): void => {
|
||||
if (syncing$.value) return;
|
||||
toggle$.next();
|
||||
},
|
||||
enabled$,
|
||||
syncing$,
|
||||
};
|
||||
}),
|
||||
),
|
||||
@@ -147,6 +162,10 @@ export class MuteState<Label, Selected> {
|
||||
this.data$.pipe(map(({ toggle }) => toggle)),
|
||||
);
|
||||
|
||||
public readonly syncing$: Behavior<boolean> = this.scope.behavior(
|
||||
this.data$.pipe(switchMap(({ syncing$ }) => syncing$)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly device: MediaDevice<Label, Selected>,
|
||||
|
||||
Reference in New Issue
Block a user