diff --git a/src/main/userMessaging/calls/CallOverlayHost.test.tsx b/src/main/userMessaging/calls/CallOverlayHost.test.tsx
index 63ef288eb5..980b97fcaf 100644
--- a/src/main/userMessaging/calls/CallOverlayHost.test.tsx
+++ b/src/main/userMessaging/calls/CallOverlayHost.test.tsx
@@ -58,12 +58,10 @@ import { closeCall, getCallOverlay, openCall } from './callOverlayStore';
const makeClient = () =>
Object.assign(new EventEmitter(), {
- baseUrl: 'https://hs.example',
getUserId: () => '@me:hs.example',
getDeviceId: () => 'DEV1',
- getAccessToken: () => 'access-token',
_unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'leave-1' })),
- _unstable_updateDelayedEvent: vi.fn(async () => ({})),
+ http: { authedRequest: vi.fn(async () => ({})) },
});
const CALL_MEMBER = 'org.matrix.msc3401.call.member';
@@ -269,23 +267,42 @@ describe('CallOverlayHost', () => {
});
it('leaves the call at once when the call UI goes away before it could leave by itself', async () => {
- const fetchMock = vi.fn(async () => new Response('{}'));
- vi.stubGlobal('fetch', fetchMock);
+ const { rerender } = render();
+ const api = await startCall();
+ await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY);
+ const client = harness.client as ReturnType;
+ harness.client = null;
+ rerender();
+
+ expect(api.stop).toHaveBeenCalled();
+ expect(client.http.authedRequest).toHaveBeenCalledTimes(1);
+ expect(client.http.authedRequest).toHaveBeenCalledWith(
+ 'POST',
+ '/delayed_events/leave-1',
+ undefined,
+ { action: 'send' },
+ { prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: true }
+ );
+ });
+
+ it('hides the widget library’s failed-download error while the call is open, and only that', async () => {
+ const original = console.error;
+ const sink = vi.fn();
+ console.error = sink;
try {
- const { rerender } = render();
- const api = await startCall();
- await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY);
- harness.client = null;
- rerender();
-
- expect(api.stop).toHaveBeenCalled();
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(fetchMock).toHaveBeenCalledWith(
- 'https://hs.example/_matrix/client/unstable/org.matrix.msc4140/delayed_events/leave-1',
- expect.objectContaining({ method: 'POST', keepalive: true, body: JSON.stringify({ action: 'send' }) })
- );
+ render();
+ await startCall();
+ const failure = new Error('Downloading the media failed with status 500');
+ console.error('error while downloading a file', failure);
+ console.error('error updating delayed event: ', failure);
+ expect(sink.mock.calls).toEqual([['error updating delayed event: ', failure]]);
+
+ act(() => closeCurrentCall());
+ expect(console.error).toBe(sink);
+ console.error('error while downloading a file', failure);
+ expect(sink).toHaveBeenCalledTimes(2);
} finally {
- vi.unstubAllGlobals();
+ console.error = original;
}
});
diff --git a/src/main/userMessaging/calls/CallWidgetFrame.tsx b/src/main/userMessaging/calls/CallWidgetFrame.tsx
index e20e77af96..2094c8fdae 100644
--- a/src/main/userMessaging/calls/CallWidgetFrame.tsx
+++ b/src/main/userMessaging/calls/CallWidgetFrame.tsx
@@ -3,6 +3,7 @@ import { ClientWidgetApi, type IWidgetApiRequest, Widget } from 'matrix-widget-a
import { type RefObject, useEffect, useRef, useState } from 'react';
import { closeCall } from './callOverlayStore';
import { ElementCallWidgetDriver, toRoomEvent } from './ElementCallWidgetDriver';
+import { quietDownloadErrors } from './quietDownloadErrors';
// matrix-js-sdk ClientEvent.Event and RoomStateEvent.Events, as strings so the SDK stays a lazily loaded chunk.
const CLIENT_EVENT = 'event';
@@ -61,6 +62,7 @@ export default function CallWidgetFrame({
});
const driver = new ElementCallWidgetDriver({ client, matrixRoomId, livekitServiceUrl });
const api = new ClientWidgetApi(widget, iframe, driver);
+ const releaseConsoleFilter = quietDownloadErrors();
api.setViewedRoomId(matrixRoomId);
const onPageHide = () => driver.sendPendingLeavesNow();
window.addEventListener('pagehide', onPageHide);
@@ -121,6 +123,7 @@ export default function CallWidgetFrame({
api.off(`action:${HANGUP_ACTION}`, onWidgetLeft);
api.off(`action:${CLOSE_ACTION}`, onWidgetLeft);
api.stop();
+ releaseConsoleFilter();
};
}, [client, matrixRoomId, livekitServiceUrl, widgetId, frameSrc, hangupRef]);
diff --git a/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts b/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts
index 65e51f06d3..1af487a510 100644
--- a/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts
+++ b/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts
@@ -1,4 +1,4 @@
-import { getHttpUriForMxc } from 'matrix-js-sdk';
+import { createClient, getHttpUriForMxc } from 'matrix-js-sdk';
import { EventDirection, OpenIDRequestState, WidgetEventCapability } from 'matrix-widget-api';
import { describe, expect, it, vi } from 'vitest';
import { allowedCapabilitiesFor, ElementCallWidgetDriver } from './ElementCallWidgetDriver';
@@ -132,10 +132,15 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => {
: (state.find(event => event.getType() === type && event.getStateKey() === stateKey) ?? null),
},
};
+ // The chat SDK's own request layer, sending through whatever `fetch` the test stubs.
+ const { http } = createClient({
+ baseUrl: 'https://matrix.example.org',
+ accessToken: 'access-token',
+ fetchFn: ((...args: Parameters) => globalThis.fetch(...args)) as typeof fetch,
+ });
+ vi.spyOn(http, 'authedRequest');
return {
room,
- baseUrl: 'https://matrix.example.org',
- getAccessToken: () => 'access-token',
getUserId: () => USER,
getDeviceId: () => DEVICE,
getRoom: vi.fn((roomId: string) => (roomId === ROOM ? room : null)),
@@ -153,7 +158,7 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => {
redactEvent: vi.fn(async () => ({ event_id: '$redaction' })),
_unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'delay-state' })),
_unstable_sendDelayedEvent: vi.fn(async () => ({ delay_id: 'delay-event' })),
- _unstable_updateDelayedEvent: vi.fn(async () => ({})),
+ http,
};
};
@@ -260,36 +265,85 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
});
it('maps delayed-event updates to the body-form update the pinned chat server serves', async () => {
- const client = makeClient();
- const driver = driverFor(client);
- await driver.cancelScheduledDelayedEvent('d1');
- await driver.restartScheduledDelayedEvent('d2');
- await driver.sendScheduledDelayedEvent('d3');
- expect(client._unstable_updateDelayedEvent.mock.calls).toEqual([
- ['d1', 'cancel'],
- ['d2', 'restart'],
- ['d3', 'send'],
- ]);
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => Response.json({}))
+ );
+ try {
+ const client = makeClient();
+ const driver = driverFor(client);
+ await driver.cancelScheduledDelayedEvent('d1');
+ await driver.restartScheduledDelayedEvent('d2');
+ await driver.sendScheduledDelayedEvent('d/3');
+ const opts = { prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: false };
+ expect(vi.mocked(client.http.authedRequest).mock.calls).toEqual([
+ ['POST', '/delayed_events/d1', undefined, { action: 'cancel' }, opts],
+ ['POST', '/delayed_events/d2', undefined, { action: 'restart' }, opts],
+ ['POST', '/delayed_events/d%2F3', undefined, { action: 'send' }, opts],
+ ]);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it.each([
+ ['cancel', 'cancelScheduledDelayedEvent'],
+ ['restart', 'restartScheduledDelayedEvent'],
+ ['send', 'sendScheduledDelayedEvent'],
+ ] as const)('sends the %s update of a delayed event once, with the session token', async (action, method) => {
+ // The real chat client, so a request it sends without the token is visible here.
+ const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (String(input).includes('/_matrix/client/versions')) {
+ return Response.json({ versions: ['v1.11'], unstable_features: { 'org.matrix.msc4140': true } });
+ }
+ // The pinned chat server refuses an update without the token.
+ return new Headers(init?.headers).has('Authorization')
+ ? Response.json({})
+ : Response.json({ errcode: 'M_MISSING_TOKEN', error: 'Missing access token' }, { status: 401 });
+ });
+ const client = createClient({
+ baseUrl: 'https://matrix.example.org',
+ accessToken: 'access-token',
+ userId: USER,
+ deviceId: DEVICE,
+ fetchFn: fetchFn as unknown as typeof fetch,
+ });
+ const driver = new ElementCallWidgetDriver({ client, matrixRoomId: ROOM, livekitServiceUrl: TOKEN_SERVICE });
+
+ await driver[method]('delay-1');
+
+ const updates = fetchFn.mock.calls.filter(([input]) => String(input).includes('/delayed_events/'));
+ expect(updates).toHaveLength(1);
+ const [input, init] = updates[0];
+ expect(String(input)).toBe(
+ 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-1'
+ );
+ expect(init?.method).toBe('POST');
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
+ expect(JSON.parse(String(init?.body))).toEqual({ action });
});
it('fires the scheduled leave at once, in a request that outlives the page, when the page goes away', async () => {
const client = makeClient();
- const fetchMock = vi.fn(async () => new Response('{}'));
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({}));
vi.stubGlobal('fetch', fetchMock);
try {
const driver = driverFor(client);
await driver.sendDelayedEvent(3600000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM);
driver.sendPendingLeavesNow();
+ // The request leaves within the page-exit task: only already-settled steps run before it, no timer.
+ for (let step = 0; step < 10; step++) {
+ await Promise.resolve();
+ }
expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(fetchMock).toHaveBeenCalledWith(
- 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state',
- {
- method: 'POST',
- keepalive: true,
- headers: { Authorization: 'Bearer access-token', 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'send' }),
- }
+ const [input, init] = fetchMock.mock.calls[0];
+ expect(String(input)).toBe(
+ 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state'
);
+ expect(init?.method).toBe('POST');
+ expect(init?.keepalive).toBe(true);
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
+ expect(JSON.parse(String(init?.body))).toEqual({ action: 'send' });
} finally {
vi.unstubAllGlobals();
}
@@ -308,7 +362,9 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
await driver.sendDelayedEvent(8000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM);
await driver.sendScheduledDelayedEvent('sent');
await driver.sendDelayedEvent(500, 'm.reaction', { key: 'x' });
+ fetchMock.mockClear();
driver.sendPendingLeavesNow();
+ await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
@@ -440,16 +496,17 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
});
it('downloads media of the chat server through its authenticated media endpoint, as the platform session', async () => {
- const fetchMock = vi.fn(async () => new Response('avatar-bytes'));
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('avatar-bytes'));
vi.stubGlobal('fetch', fetchMock);
try {
const { file } = await driverFor(makeClient()).downloadFile('mxc://matrix.example.org/abc123');
- expect(fetchMock).toHaveBeenCalledWith(
- 'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true',
- {
- headers: { Authorization: 'Bearer access-token' },
- }
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [input, init] = fetchMock.mock.calls[0];
+ expect(String(input)).toBe(
+ 'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true'
);
+ expect(init?.method).toBe('GET');
+ expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token');
// fetch's Blob and jsdom's global Blob can come from different realms, so
// `instanceof Blob` depends on the runtime; check the brand instead.
expect(Object.prototype.toString.call(file)).toBe('[object Blob]');
@@ -472,6 +529,118 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
}
});
+ it.each([
+ ['refused', () => Promise.resolve(new Response('{}', { status: 500 }))],
+ ['unreachable', () => Promise.reject(new TypeError('Failed to fetch'))],
+ [
+ 'cut off while reading',
+ () =>
+ Promise.resolve(
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.error(new TypeError('network error'));
+ },
+ })
+ )
+ ),
+ ],
+ ])('fails a download that was %s again for a while, without a request', async (_, answer) => {
+ const fetchMock = vi.fn(answer);
+ vi.stubGlobal('fetch', fetchMock);
+ try {
+ const driver = driverFor(makeClient());
+ await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow();
+ await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // Other media of the call, and the same media in another call, are still fetched.
+ fetchMock.mockImplementation(async () => new Response('avatar-bytes'));
+ await expect(driver.downloadFile('mxc://matrix.example.org/fine')).resolves.toBeDefined();
+ await expect(driverFor(makeClient()).downloadFile('mxc://matrix.example.org/broken')).resolves.toBeDefined();
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('shares one request among downloads of the same media that overlap, and fetches it afresh after a success', async () => {
+ let answer: (response: Response) => void = () => undefined;
+ const fetchMock = vi.fn(() => new Promise(resolve => (answer = resolve)));
+ vi.stubGlobal('fetch', fetchMock);
+ try {
+ const driver = driverFor(makeClient());
+ const first = driver.downloadFile('mxc://matrix.example.org/broken');
+ const second = driver.downloadFile('mxc://matrix.example.org/broken');
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ answer(new Response('{}', { status: 500 }));
+ await expect(first).rejects.toMatchObject({ httpStatus: 500 });
+ await expect(second).rejects.toMatchObject({ httpStatus: 500 });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ const third = driver.downloadFile('mxc://matrix.example.org/fine');
+ const fourth = driver.downloadFile('mxc://matrix.example.org/fine');
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ answer(new Response('avatar-bytes'));
+ await expect(third).resolves.toBeDefined();
+ await expect(fourth).resolves.toBeDefined();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ const fifth = driver.downloadFile('mxc://matrix.example.org/fine');
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
+ answer(new Response('avatar-bytes'));
+ await expect(fifth).resolves.toBeDefined();
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('fetches a failed download again once the hold has passed', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout'] });
+ const fetchMock = vi.fn(async () => new Response('{}', { status: 500 }));
+ vi.stubGlobal('fetch', fetchMock);
+ try {
+ const driver = driverFor(makeClient());
+ await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow();
+ vi.advanceTimersByTime(59_000);
+ await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1_000);
+ fetchMock.mockImplementation(async () => new Response('avatar-bytes'));
+ await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).resolves.toBeDefined();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ } finally {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('gives up a download that hangs, so later asks are not held behind it', async () => {
+ vi.useFakeTimers({ toFake: ['setTimeout'] });
+ const fetchMock = vi.fn(
+ (_: RequestInfo | URL, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
+ })
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ try {
+ const driver = driverFor(makeClient());
+ const pending = driver.downloadFile('mxc://matrix.example.org/stuck');
+ const settled = vi.fn();
+ pending.then(settled, settled);
+ await vi.advanceTimersByTimeAsync(19_999);
+ expect(settled).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(1);
+ await expect(pending).rejects.toThrow();
+ } finally {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ }
+ });
+
it('fails the download when the chat server refuses it, so the call UI falls back to initials', async () => {
vi.stubGlobal(
'fetch',
diff --git a/src/main/userMessaging/calls/ElementCallWidgetDriver.ts b/src/main/userMessaging/calls/ElementCallWidgetDriver.ts
index 70e0f96116..9fa7c08e8c 100644
--- a/src/main/userMessaging/calls/ElementCallWidgetDriver.ts
+++ b/src/main/userMessaging/calls/ElementCallWidgetDriver.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import type { MatrixClient, MatrixEvent, Method } from 'matrix-js-sdk';
import {
type Capability,
type IOpenIDUpdate,
@@ -61,10 +61,18 @@ const allowedCapabilitiesFor = (roomId: string, userId: string, deviceId: string
type DelayedEventAction = 'cancel' | 'restart' | 'send';
+// The unstable path prefix of the chat server's delayed events.
+const DELAYED_EVENTS_PREFIX = '/_matrix/client/unstable/org.matrix.msc4140';
+// The chat SDK's request methods, as strings: the SDK is loaded lazily and only its types are imported here.
+const POST = 'POST' as Method;
+const GET = 'GET' as Method;
+/** A failed download is not retried for this long, so tile resizes don't repeat it; then it may recover. */
+const FAILED_DOWNLOAD_HOLD_MS = 60_000;
+/** A download that hangs is given up after this long, so later asks are not held behind it. */
+const DOWNLOAD_TIMEOUT_MS = 20_000;
+
/** The part of the platform's Matrix client the driver uses, typed loosely enough for arbitrary event types. */
type CallHostClient = {
- baseUrl: string;
- getAccessToken(): string | null;
getUserId(): string | null;
getDeviceId(): string | null;
getRoom: MatrixClient['getRoom'];
@@ -88,8 +96,7 @@ type CallHostClient = {
eventType: string,
content: unknown
): Promise<{ delay_id: string }>;
- // The update form with the action in the body is the one the pinned chat server serves.
- _unstable_updateDelayedEvent(delayId: string, action: DelayedEventAction): Promise;
+ http: Pick;
};
type DriverOptions = {
@@ -119,6 +126,13 @@ class ElementCallWidgetDriver extends WidgetDriver {
private readonly allowed: Set;
/** Delayed leaves of this device's call membership that are still waiting on the chat server. */
private readonly pendingLeaves = new Set();
+ /**
+ * Downloads still in flight, and those that failed recently. The call UI asks
+ * for an avatar again every time its tile is resized; overlapping asks share
+ * one request, and a broken one fails at once for a while instead of hitting
+ * the chat server again.
+ */
+ private readonly downloads = new Map>();
constructor({ client, matrixRoomId, livekitServiceUrl }: DriverOptions) {
super();
@@ -178,18 +192,38 @@ class ElementCallWidgetDriver extends WidgetDriver {
return { roomId: target, delayId: response.delay_id };
}
+ /**
+ * The update form with the action in the body, the one the pinned chat server
+ * serves, sent with the session's token from the start. The chat SDK sends it
+ * without a token first and retries with one only after the chat server
+ * refuses, which logs a failed request on every call heartbeat.
+ */
+ private async updateDelayedEvent(
+ delayId: string,
+ action: DelayedEventAction,
+ { keepAlive = false }: { keepAlive?: boolean } = {}
+ ): Promise {
+ await this.client.http.authedRequest(
+ POST,
+ `/delayed_events/${encodeURIComponent(delayId)}`,
+ undefined,
+ { action },
+ { prefix: DELAYED_EVENTS_PREFIX, keepAlive }
+ );
+ }
+
public async cancelScheduledDelayedEvent(delayId: string): Promise {
this.pendingLeaves.delete(delayId);
- await this.client._unstable_updateDelayedEvent(delayId, 'cancel');
+ await this.updateDelayedEvent(delayId, 'cancel');
}
public async restartScheduledDelayedEvent(delayId: string): Promise {
- await this.client._unstable_updateDelayedEvent(delayId, 'restart');
+ await this.updateDelayedEvent(delayId, 'restart');
}
public async sendScheduledDelayedEvent(delayId: string): Promise {
this.pendingLeaves.delete(delayId);
- await this.client._unstable_updateDelayedEvent(delayId, 'send');
+ await this.updateDelayedEvent(delayId, 'send');
}
/**
@@ -197,21 +231,12 @@ class ElementCallWidgetDriver extends WidgetDriver {
* page unloads (reload, close, navigation) or the host tears the call UI
* down. Its own leave does not survive that, and its delayed leave may be
* set to fire much later. A leave the call UI already sent or cancelled is
- * not in the set any more. `keepalive` lets the browser finish the request
- * after the page is gone, which the chat client's own requests can't.
+ * not in the set any more. `keepAlive` lets the browser finish the request
+ * after the page is gone.
*/
public sendPendingLeavesNow(): void {
- const accessToken = this.client.getAccessToken();
for (const delayId of this.pendingLeaves) {
- fetch(
- `${this.client.baseUrl}/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`,
- {
- method: 'POST',
- keepalive: true,
- headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({ action: 'send' }),
- }
- ).catch(() => {
+ this.updateDelayedEvent(delayId, 'send', { keepAlive: true }).catch(() => {
// The page is going away; a leave that does not arrive still fires when its delay runs out.
});
}
@@ -369,11 +394,34 @@ class ElementCallWidgetDriver extends WidgetDriver {
if (!url) {
throw new Error('Only chat server media can be downloaded');
}
- const response = await fetch(url, { headers: { Authorization: `Bearer ${this.client.getAccessToken()}` } });
- if (!response.ok) {
- throw new Error(`Downloading the media failed with status ${response.status}`);
+ const known = this.downloads.get(contentUri);
+ if (known) {
+ return known;
}
- return { file: await response.blob() };
+ const download = this.fetchMedia(url);
+ this.downloads.set(contentUri, download);
+ // A successful download is fetched afresh next time; a failed one is held for a while, then retried.
+ const forget = () => {
+ if (this.downloads.get(contentUri) === download) {
+ this.downloads.delete(contentUri);
+ }
+ };
+ download.then(forget, () => {
+ setTimeout(forget, FAILED_DOWNLOAD_HOLD_MS);
+ });
+ return download;
+ }
+
+ private async fetchMedia(url: string): Promise<{ file: XMLHttpRequestBodyInit }> {
+ const { origin, pathname, searchParams } = new URL(url);
+ const file = await this.client.http.authedRequest(
+ GET,
+ pathname,
+ Object.fromEntries(searchParams),
+ undefined,
+ { baseUrl: origin, prefix: '', rawResponseBody: true, localTimeoutMs: DOWNLOAD_TIMEOUT_MS }
+ );
+ return { file };
}
public getRtcLivekitToken(): Promise {
diff --git a/src/main/userMessaging/calls/quietDownloadErrors.test.ts b/src/main/userMessaging/calls/quietDownloadErrors.test.ts
new file mode 100644
index 0000000000..b5eb421096
--- /dev/null
+++ b/src/main/userMessaging/calls/quietDownloadErrors.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { quietDownloadErrors } from './quietDownloadErrors';
+
+const MESSAGE = 'error while downloading a file';
+
+describe('quietDownloadErrors', () => {
+ const original = console.error;
+ let sink: ReturnType void>>;
+
+ beforeEach(() => {
+ sink = vi.fn<(...args: unknown[]) => void>();
+ console.error = sink;
+ });
+ afterEach(() => {
+ console.error = original;
+ });
+
+ it('drops exactly the failed-download message and passes everything else on with its this and arguments', () => {
+ const release = quietDownloadErrors();
+ const failure = new Error('500');
+ console.error(MESSAGE, failure);
+ console.error(`${MESSAGE}!`, failure);
+ console.error(failure, MESSAGE);
+ const target = { name: 'caller' };
+ console.error.call(target, 'other', 1, 2);
+ expect(sink.mock.calls).toEqual([
+ [`${MESSAGE}!`, failure],
+ [failure, MESSAGE],
+ ['other', 1, 2],
+ ]);
+ expect(sink.mock.contexts[2]).toBe(target);
+ release();
+ expect(console.error).toBe(sink);
+ console.error(MESSAGE);
+ expect(sink).toHaveBeenLastCalledWith(MESSAGE);
+ });
+
+ it('never removes a wrapper installed over it, and passes everything through once released', () => {
+ const release = quietDownloadErrors();
+ const ours = console.error;
+ const later = vi.fn((...args: unknown[]) => ours(...args));
+ console.error = later;
+ release();
+ expect(console.error).toBe(later);
+ console.error(MESSAGE);
+ expect(sink).toHaveBeenCalledWith(MESSAGE);
+ });
+
+ it('filters again for a call opened after one was replaced over it', () => {
+ quietDownloadErrors();
+ const replacement = vi.fn<(...args: unknown[]) => void>();
+ console.error = replacement;
+ const release = quietDownloadErrors();
+ console.error(MESSAGE);
+ expect(replacement).not.toHaveBeenCalled();
+ release();
+ expect(console.error).toBe(replacement);
+ });
+});
diff --git a/src/main/userMessaging/calls/quietDownloadErrors.ts b/src/main/userMessaging/calls/quietDownloadErrors.ts
new file mode 100644
index 0000000000..0bc6e8c902
--- /dev/null
+++ b/src/main/userMessaging/calls/quietDownloadErrors.ts
@@ -0,0 +1,31 @@
+/**
+ * The widget library logs this, with the error, every time the call UI fails to
+ * download a file through the host. The call UI asks for an avatar again on
+ * every tile resize, so one broken avatar floods the console for the whole
+ * call. A temporary hide until the failing downloads themselves are fixed.
+ */
+const DOWNLOAD_ERROR_MESSAGE = 'error while downloading a file';
+
+/**
+ * Hides the widget library's "error while downloading a file" console error
+ * until the returned release is called; every other console error passes
+ * through unchanged. Once released, the filter passes everything through, and
+ * it is taken out of `console.error` if nothing was wrapped over it since.
+ */
+export const quietDownloadErrors = (): (() => void) => {
+ const previous = console.error;
+ let active = true;
+ const wrapper = function (this: unknown, ...args: unknown[]) {
+ if (active && args[0] === DOWNLOAD_ERROR_MESSAGE) {
+ return;
+ }
+ return previous.apply(this, args);
+ };
+ console.error = wrapper;
+ return () => {
+ active = false;
+ if (console.error === wrapper) {
+ console.error = previous;
+ }
+ };
+};