fix(calls,matrix): address review findings from agent code review
- CallEmbed watchdog now SELF-HEALS: a genuine ready/joined signal arriving after the 25s timeout clears the error and notifies subscribers with undefined, so a slow-but-successful EC load no longer strands the user on the recovery screen over a live call. Listener dispatch wrapped in try/catch. - ringtones: synth notes route through a per-session master gain; stop() ramps it to 0 so the ring is silenced instantly on answer instead of letting the last scheduled phrase ring out over call audio. - IncomingCallBanner: ping fires exactly once per incoming call (guarded by refEventId) instead of re-pinging when ringtone settings change mid-banner. - focusCameraParticipant: try multiple tile selectors (EC labels vary by version), defer the tile click past EC's async spotlight layout switch (rAF x2), and dev-warn when no tile matches so testers get signal. - uploadContent: a cancelled upload (mx.cancelUpload -> AbortError) is no longer treated as retryable — previously the retry loop could resurrect an upload the user just cancelled. Also retry on 408. - addRoomIdToMDirect/removeRoomIdFromMDirect: guard against a corrupt m.direct whose values aren't arrays. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -356,20 +356,48 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
const doc = this.document;
|
||||
if (!doc) return;
|
||||
|
||||
// Find the mute icon / aria-label element that identifies this participant
|
||||
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`);
|
||||
// Walk up to the nearest video tile container
|
||||
const tile =
|
||||
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ??
|
||||
userEl?.closest<HTMLElement>('[data-video-fit]');
|
||||
// EC labels participant tiles inconsistently across versions — the user's
|
||||
// matrix id may be the full aria-label, a substring of it, or carried on a
|
||||
// data attribute (and sometimes the visible label is the display name, not
|
||||
// the id at all). Try several strategies before giving up, then walk up to
|
||||
// the enclosing video tile.
|
||||
const findTile = (): HTMLElement | undefined => {
|
||||
const escaped = CSS.escape(userId);
|
||||
const el =
|
||||
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
|
||||
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
|
||||
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
|
||||
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
|
||||
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
|
||||
undefined;
|
||||
return (
|
||||
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
|
||||
el?.closest<HTMLElement>('[data-video-fit]') ??
|
||||
el ??
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
if (!this.spotlight) {
|
||||
this.spotlightButton?.click();
|
||||
const applyFocus = () => {
|
||||
const tile = findTile();
|
||||
if (tile) {
|
||||
tile.click();
|
||||
} else if (import.meta.env.DEV) {
|
||||
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.spotlight) {
|
||||
// Already in spotlight — pin immediately.
|
||||
applyFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (tile) {
|
||||
tile.click();
|
||||
}
|
||||
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
|
||||
// tile in the same tick would land in the old (grid) DOM. Toggle spotlight,
|
||||
// then click on a later frame once the spotlight tiles have mounted.
|
||||
this.spotlightButton?.click();
|
||||
requestAnimationFrame(() => requestAnimationFrame(applyFocus));
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
|
||||
@@ -70,7 +70,9 @@ export class CallEmbed {
|
||||
|
||||
private loadError?: CallLoadErrorReason;
|
||||
|
||||
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>();
|
||||
private readonly loadErrorListeners = new Set<
|
||||
(reason: CallLoadErrorReason | undefined) => void
|
||||
>();
|
||||
|
||||
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
|
||||
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
|
||||
@@ -375,17 +377,44 @@ export class CallEmbed {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
|
||||
this.loadErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(reason);
|
||||
} catch {
|
||||
// a misbehaving subscriber must not block the others
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the load lifecycle as settled. Called on success (no reason) or on
|
||||
* failure (reason set). Idempotent so the first signal wins.
|
||||
* Marks the load lifecycle as settled.
|
||||
*
|
||||
* - Failure (reason set): the FIRST failure wins; a later success can still
|
||||
* heal it (below). Once we've genuinely succeeded, later spurious failures
|
||||
* are ignored.
|
||||
* - Success (no reason): always clears the watchdog. Crucially, if we had
|
||||
* previously settled as a failure (e.g. the 25s watchdog fired on a slow
|
||||
* network but EC then finished loading), we self-heal: clear the error and
|
||||
* notify subscribers with `undefined` so the recovery UI dismisses itself
|
||||
* instead of stranding the user on an error screen over a live call.
|
||||
*/
|
||||
private settleLoad(reason?: CallLoadErrorReason): void {
|
||||
if (this.loadSettled) return;
|
||||
this.loadSettled = true;
|
||||
this.clearLoadWatchdog();
|
||||
if (reason) {
|
||||
if (this.loadSettled) return;
|
||||
this.loadSettled = true;
|
||||
this.clearLoadWatchdog();
|
||||
this.loadError = reason;
|
||||
this.loadErrorListeners.forEach((cb) => cb(reason));
|
||||
this.notifyLoadListeners(reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearLoadWatchdog();
|
||||
const wasFailed = this.loadError !== undefined;
|
||||
this.loadSettled = true;
|
||||
this.loadError = undefined;
|
||||
if (wasFailed) {
|
||||
this.notifyLoadListeners(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +431,7 @@ export class CallEmbed {
|
||||
* immediately so late subscribers still see the error.
|
||||
* @returns an unsubscribe function.
|
||||
*/
|
||||
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
|
||||
public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
|
||||
this.loadErrorListeners.add(callback);
|
||||
if (this.loadError) callback(this.loadError);
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user