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:
2026-06-26 18:15:51 -04:00
parent fc8eb70617
commit bc85cd4984
5 changed files with 131 additions and 31 deletions
+7 -2
View File
@@ -289,11 +289,16 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
); );
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone // Single soft ping (non-looping) on arrival, respecting the chosen ringtone
// + volume. We intentionally do NOT loop here — the user is mid-call. // + volume. We intentionally do NOT loop here — the user is mid-call — and we
// ping exactly once per incoming call, not again if the user happens to tweak
// ringtone settings while the banner is showing.
const pingedRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (info.notificationType !== 'ring') return; if (info.notificationType !== 'ring') return;
if (pingedRef.current === info.refEventId) return;
pingedRef.current = info.refEventId;
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100))); previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, ringtoneId, ringtoneVolume]); }, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
useEffect(() => { useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now(); const remaining = info.senderTs + info.lifetime - Date.now();
+39 -11
View File
@@ -356,20 +356,48 @@ export class CallControl extends EventEmitter implements CallControlState {
const doc = this.document; const doc = this.document;
if (!doc) return; if (!doc) return;
// Find the mute icon / aria-label element that identifies this participant // EC labels participant tiles inconsistently across versions — the user's
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`); // matrix id may be the full aria-label, a substring of it, or carried on a
// Walk up to the nearest video tile container // data attribute (and sometimes the visible label is the display name, not
const tile = // the id at all). Try several strategies before giving up, then walk up to
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ?? // the enclosing video tile.
userEl?.closest<HTMLElement>('[data-video-fit]'); 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) { const applyFocus = () => {
this.spotlightButton?.click(); 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) { // Switching to spotlight re-renders EC's layout asynchronously; clicking the
tile.click(); // 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() { public dispose() {
+37 -8
View File
@@ -70,7 +70,9 @@ export class CallEmbed {
private loadError?: CallLoadErrorReason; 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() // Arrow-function class fields so dispose() passes the exact same reference to mx.off()
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev); 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 * Marks the load lifecycle as settled.
* failure (reason set). Idempotent so the first signal wins. *
* - 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 { private settleLoad(reason?: CallLoadErrorReason): void {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
if (reason) { if (reason) {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
this.loadError = reason; 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. * immediately so late subscribers still see the error.
* @returns an unsubscribe function. * @returns an unsubscribe function.
*/ */
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void { public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
this.loadErrorListeners.add(callback); this.loadErrorListeners.add(callback);
if (this.loadError) callback(this.loadError); if (this.loadError) callback(this.loadError);
return () => { return () => {
+11 -3
View File
@@ -169,12 +169,17 @@ const matrixErrorFromUnknown = (e: unknown): MatrixError => {
// HTTP statuses that should not be retried — client errors are deterministic // HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry. // (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => { const isRetryableUploadError = (e: unknown): boolean => {
// A user-cancelled / aborted upload must never be retried. matrix-js-sdk's
// mx.cancelUpload() rejects the upload with a DOMException named "AbortError";
// without this guard the retry loop would resurrect an upload the user just
// cancelled.
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return false;
if (e instanceof MatrixError) { if (e instanceof MatrixError) {
const status = e.httpStatus; const status = e.httpStatus;
// No status => network/transport failure (transient): retry. // No status => network/transport failure (transient): retry.
if (typeof status !== 'number') return true; if (typeof status !== 'number') return true;
// Retry on rate-limiting and server-side (5xx) errors only. // Retry on request-timeout, rate-limiting and server-side (5xx) errors only.
return status === 429 || status >= 500; return status === 408 || status === 429 || status >= 500;
} }
// Non-Matrix errors are typically network/transport failures: retry. // Non-Matrix errors are typically network/transport failures: retry.
return true; return true;
@@ -307,6 +312,8 @@ export const addRoomIdToMDirect = async (
// (it can only be a DM room for one person) // (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => { Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId]; const roomIds = userIdToRoomIds[targetUserId];
// Guard against a corrupt m.direct where a value isn't an array.
if (!Array.isArray(roomIds)) return;
if (targetUserId !== userId) { if (targetUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId); const indexOfRoomId = roomIds.indexOf(roomId);
@@ -316,7 +323,7 @@ export const addRoomIdToMDirect = async (
} }
}); });
const roomIds = userIdToRoomIds[userId] || []; const roomIds = Array.isArray(userIdToRoomIds[userId]) ? userIdToRoomIds[userId] : [];
if (roomIds.indexOf(roomId) === -1) { if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId); roomIds.push(roomId);
} }
@@ -334,6 +341,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
Object.keys(userIdToRoomIds).forEach((targetUserId) => { Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId]; const roomIds = userIdToRoomIds[targetUserId];
if (!Array.isArray(roomIds)) return;
const indexOfRoomId = roomIds.indexOf(roomId); const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) { if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1); roomIds.splice(indexOfRoomId, 1);
+37 -7
View File
@@ -78,7 +78,7 @@ const PHRASES: Record<
}, },
}; };
const playPhrase = (style: SynthStyle, volume: number): void => { const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
const ctx = getCtx(); const ctx = getCtx();
if (!ctx) return; if (!ctx) return;
const { type, gain: peak, notes } = PHRASES[style]; const { type, gain: peak, notes } = PHRASES[style];
@@ -96,7 +96,7 @@ const playPhrase = (style: SynthStyle, volume: number): void => {
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015); gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur); gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain); osc.connect(gain);
gain.connect(ctx.destination); gain.connect(destination);
osc.start(start); osc.start(start);
osc.stop(start + dur + 0.02); osc.stop(start + dur + 0.02);
}); });
@@ -121,11 +121,41 @@ const startClassic = (volume: number, loop: boolean): (() => void) => {
}; };
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => { const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
playPhrase(style, volume); const ctx = getCtx();
if (!loop) return () => undefined; if (!ctx) return () => undefined;
const period = PHRASES[style].period * 1000; // All notes route through a per-session master gain so stop() can silence
const id = window.setInterval(() => playPhrase(style, volume), period); // everything instantly — including notes already scheduled slightly in the
return () => window.clearInterval(id); // future — instead of letting the last phrase ring out after the user answers.
const master = ctx.createGain();
master.gain.value = 1;
master.connect(ctx.destination);
playPhrase(style, volume, master);
const id = loop
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
: 0;
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (id) window.clearInterval(id);
try {
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setValueAtTime(master.gain.value, now);
master.gain.linearRampToValueAtTime(0, now + 0.03);
} catch {
/* context may be closed */
}
window.setTimeout(() => {
try {
master.disconnect();
} catch {
/* already disconnected */
}
}, 100);
};
}; };
/** /**