fix(call): Wave-1 audit fixes (calls host side)
- C-H1: forceState only on FIRST join; on EC reconnect re-arm the fork handlers (resendForkState — deafen+quality only) instead of clobbering live mic/video/ deafen back to the join-time snapshot. - C-H2: AFK auto-mute reads the fork's io.lotus.call_state VAD of the LOCAL published track instead of getUserMedia on the browser DEFAULT mic (which could measure silence while the user spoke on another device → auto-mute an active speaker). Fails safe (never mutes) when call_state is null OR empty. - C-H3: control observer re-binds after EC re-renders (body subtree:true + 100ms debounce) with an early-return so unchanged state doesn't re-render. - C-M3 setQuality join-gated; C-M4 hangup 4s fallback dispose (idempotent); C-M5 PTT no longer silently un-deafens; C-M6 screenshare-audio mute resets on stop; C-L4 deafen key works in the iframe; C-L6 setState-after-unmount guards. Reviewed (C-H2 [] fail-safe + C-H3 re-render guard applied). tsc/eslint/prettier clean, build OK, 677 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,8 +29,22 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
|
||||
private controlMutationObserver: MutationObserver;
|
||||
|
||||
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
|
||||
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
|
||||
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _pipMode = false;
|
||||
|
||||
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||
private lastQuality: LotusQualityPayload | null = null;
|
||||
|
||||
// C-M5: set true by CallControls while a push-to-talk key is held. A PTT hold
|
||||
// unmutes the mic transiently, and onMediaState() must NOT treat that as a
|
||||
// user-initiated unmute that auto-undeafens the user.
|
||||
public pttActive = false;
|
||||
|
||||
// P6-2: mirrors CallEmbed.joined. Set true from forceState(), which CallEmbed
|
||||
// invokes only from onCallJoined(). Gates io.lotus.set_deafen so we never send
|
||||
// before the fork's widget handler mounts (pre-join sends pend to a 10s
|
||||
@@ -153,19 +167,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// this.joined was still false, so it was gated — this is the first send.)
|
||||
this.joined = true;
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
}
|
||||
|
||||
/**
|
||||
* C-H1 / C-M3: re-push the sticky fork-side state (deafen + quality) after an
|
||||
* EC reconnect. Unlike forceState() this does NOT touch mic/video, so a
|
||||
* reconnect can't clobber the user's live media state — it only re-arms the
|
||||
* fork handlers that remount on reconnect.
|
||||
*/
|
||||
public resendForkState(): void {
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
}
|
||||
|
||||
public startObserving() {
|
||||
if (!this.document) return;
|
||||
|
||||
// C-H3: watch the whole body subtree (not just direct children) so we
|
||||
// re-bind the control observer when EC re-renders its controls deeper in the
|
||||
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
|
||||
this.bodyMutationObserver.observe(this.document.body, {
|
||||
childList: true,
|
||||
subtree: false, // only direct children of body
|
||||
subtree: true,
|
||||
});
|
||||
this.onBodyMutation();
|
||||
this.applyBodyMutation();
|
||||
}
|
||||
|
||||
private onBodyMutation() {
|
||||
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
|
||||
if (this.bodyMutationTimer !== undefined) return;
|
||||
this.bodyMutationTimer = setTimeout(() => {
|
||||
this.bodyMutationTimer = undefined;
|
||||
this.applyBodyMutation();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
private applyBodyMutation() {
|
||||
if (!this.document) return;
|
||||
|
||||
this.document.body.style.setProperty('background', 'none', 'important');
|
||||
@@ -266,22 +304,43 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.state = state;
|
||||
this.emitStateUpdate();
|
||||
|
||||
if (this.microphone && !this.sound) {
|
||||
// C-M5: auto-undeafen when the mic turns on, but NOT for a transient
|
||||
// push-to-talk unmute — a PTT tap while deafened must not silently
|
||||
// un-deafen the user.
|
||||
if (this.microphone && !this.sound && !this.pttActive) {
|
||||
this.toggleSound();
|
||||
}
|
||||
}
|
||||
|
||||
private onControlMutation() {
|
||||
const wasScreensharing = this.screenshare;
|
||||
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
|
||||
const spotlight: boolean = this.spotlightButton?.checked ?? false;
|
||||
|
||||
// C-M6: when a screenshare stops, clear the screenshare-audio mute so a
|
||||
// later screenshare doesn't start pre-muted.
|
||||
const screenshareAudioMuted =
|
||||
wasScreensharing && !screenshare ? false : this.screenshareAudioMuted;
|
||||
|
||||
// C-H3: the body observer now watches subtree:true, so this fires on any DOM
|
||||
// churn in EC's controls. Only re-emit (→ re-render every consumer) when one
|
||||
// of the values this method derives actually changed — microphone/video/sound
|
||||
// are copied unchanged from the current state here.
|
||||
if (
|
||||
this.state.screenshare === screenshare &&
|
||||
this.state.spotlight === spotlight &&
|
||||
this.state.screenshareAudioMuted === screenshareAudioMuted
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = new CallControlState(
|
||||
this.microphone,
|
||||
this.video,
|
||||
this.sound,
|
||||
screenshare,
|
||||
spotlight,
|
||||
this.screenshareAudioMuted,
|
||||
screenshareAudioMuted,
|
||||
);
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
@@ -423,10 +482,25 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
* clamped fork-side, so out-of-range input can't brick the encoder.
|
||||
*/
|
||||
public setQuality(settings: LotusQualityPayload): void {
|
||||
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
|
||||
// C-M3: remember the request and only send once joined; sendQuality() gates
|
||||
// on this.joined so a pre-join call is a no-op that we replay on join.
|
||||
this.lastQuality = settings;
|
||||
this.sendQuality();
|
||||
}
|
||||
|
||||
// C-M3: push the last-requested quality to the fork. Gated on this.joined so
|
||||
// we never send io.lotus.set_quality before the fork's handler mounts (a
|
||||
// pre-join send would pend to a 10s widget timeout).
|
||||
private sendQuality(): void {
|
||||
if (!this.joined || !this.lastQuality) return;
|
||||
this.call.transport.send('io.lotus.set_quality', this.lastQuality).catch(() => undefined);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
if (this.bodyMutationTimer !== undefined) {
|
||||
clearTimeout(this.bodyMutationTimer);
|
||||
this.bodyMutationTimer = undefined;
|
||||
}
|
||||
this.bodyMutationObserver.disconnect();
|
||||
this.controlMutationObserver.disconnect();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export class CallEmbed {
|
||||
|
||||
public joined = false;
|
||||
|
||||
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||
public disposed = false;
|
||||
|
||||
// [lotus #2] Latest per-participant state from io.lotus.call_state, or null
|
||||
// until the fork sends the first one. When non-null, the speaker/mute hooks
|
||||
// read it instead of scraping the EC iframe DOM.
|
||||
@@ -403,6 +407,8 @@ export class CallEmbed {
|
||||
* @param opts
|
||||
*/
|
||||
public dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.disposables.forEach((disposable) => {
|
||||
disposable();
|
||||
});
|
||||
@@ -501,9 +507,19 @@ export class CallEmbed {
|
||||
|
||||
private onCallJoined(): void {
|
||||
this.settleLoad();
|
||||
this.joined = true;
|
||||
this.applyStyles();
|
||||
this.control.startObserving();
|
||||
|
||||
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
|
||||
// once-guard). forceState() would reset live mic/video/deafen back to the
|
||||
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
|
||||
// re-apply styles/observers (above) and re-push the sticky fork state
|
||||
// (deafen/quality), leaving the user's live media state untouched.
|
||||
if (this.joined) {
|
||||
this.control.resendForkState();
|
||||
return;
|
||||
}
|
||||
this.joined = true;
|
||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||
this.control.forceState(this.initialState);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user