Skip to content
Open
24 changes: 23 additions & 1 deletion src/main/userMessaging/calls/CallOverlayHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ const makeClient = () =>
getDeviceId: () => 'DEV1',
getAccessToken: () => 'access-token',
_unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'leave-1' })),
_unstable_updateDelayedEvent: vi.fn(async () => ({})),
doesServerSupportUnstableFeature: vi.fn(async () => true),
http: { authedRequest: vi.fn(async () => ({})) },
});

const CALL_MEMBER = 'org.matrix.msc3401.call.member';
Expand Down Expand Up @@ -289,6 +290,27 @@ describe('CallOverlayHost', () => {
}
});

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 {
render(<CallOverlayHost />);
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 {
console.error = original;
}
});

it('sends no second leave when the call UI already left before closing', async () => {
const fetchMock = vi.fn(async () => new Response('{}'));
vi.stubGlobal('fetch', fetchMock);
Expand Down
3 changes: 3 additions & 0 deletions src/main/userMessaging/calls/CallWidgetFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function CallWidgetFrame({
url: new URL(frameSrc, window.location.origin).toString(),
});
const driver = new ElementCallWidgetDriver({ client, matrixRoomId, livekitServiceUrl });
const releaseConsoleFilter = quietDownloadErrors();
const api = new ClientWidgetApi(widget, iframe, driver);
api.setViewedRoomId(matrixRoomId);
const onPageHide = () => driver.sendPendingLeavesNow();
Expand Down Expand Up @@ -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]);

Expand Down
124 changes: 117 additions & 7 deletions src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -153,7 +153,8 @@ 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 () => ({})),
doesServerSupportUnstableFeature: vi.fn(async (feature: string) => feature === 'org.matrix.msc4140'),
http: { authedRequest: vi.fn(async () => ({})) },
};
};

Expand Down Expand Up @@ -264,14 +265,59 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
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'],
await driver.sendScheduledDelayedEvent('d/3');
const prefix = { prefix: '/_matrix/client/unstable/org.matrix.msc4140' };
expect(client.http.authedRequest.mock.calls).toEqual([
['POST', '/delayed_events/d1', undefined, { action: 'cancel' }, prefix],
['POST', '/delayed_events/d2', undefined, { action: 'restart' }, prefix],
['POST', '/delayed_events/d%2F3', undefined, { action: 'send' }, prefix],
]);
});

it('refuses delayed-event updates without a request when the chat server lacks delayed events', async () => {
const client = makeClient();
client.doesServerSupportUnstableFeature.mockResolvedValue(false);
await expect(driverFor(client).restartScheduledDelayedEvent('d1')).rejects.toThrow();
expect(client.http.authedRequest).not.toHaveBeenCalled();
});

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('{}'));
Expand Down Expand Up @@ -472,6 +518,70 @@ 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 the rest of the call, 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<Response>(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');
answer(new Response('{}', { status: 500 }));
await expect(first).rejects.toThrow('status 500');
await expect(second).rejects.toThrow('status 500');
expect(fetchMock).toHaveBeenCalledTimes(1);

const third = driver.downloadFile('mxc://matrix.example.org/fine');
const fourth = driver.downloadFile('mxc://matrix.example.org/fine');
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');
answer(new Response('avatar-bytes'));
await expect(fifth).resolves.toBeDefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
} finally {
vi.unstubAllGlobals();
}
});

it('fails the download when the chat server refuses it, so the call UI falls back to initials', async () => {
vi.stubGlobal(
'fetch',
Expand Down
64 changes: 59 additions & 5 deletions src/main/userMessaging/calls/ElementCallWidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ const allowedCapabilitiesFor = (roomId: string, userId: string, deviceId: string

type DelayedEventAction = 'cancel' | 'restart' | 'send';

// The chat SDK's delayed-events feature name and its unstable path prefix.
const DELAYED_EVENTS_FEATURE = 'org.matrix.msc4140';
const DELAYED_EVENTS_PREFIX = `/_matrix/client/unstable/${DELAYED_EVENTS_FEATURE}`;

/** The part of the platform's Matrix client the driver uses, typed loosely enough for arbitrary event types. */
type CallHostClient = {
baseUrl: string;
Expand Down Expand Up @@ -88,8 +92,16 @@ 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<unknown>;
doesServerSupportUnstableFeature(feature: string): Promise<boolean>;
http: {
authedRequest(
method: 'POST',
path: string,
queryParams: undefined,
body: { action: DelayedEventAction },
opts: { prefix: string }
): Promise<unknown>;
};
};

type DriverOptions = {
Expand Down Expand Up @@ -119,6 +131,13 @@ class ElementCallWidgetDriver extends WidgetDriver {
private readonly allowed: Set<Capability>;
/** Delayed leaves of this device's call membership that are still waiting on the chat server. */
private readonly pendingLeaves = new Set<string>();
/**
* Downloads still in flight, and those that failed in this call. 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 instead of hitting the
* chat server again.
*/
private readonly downloads = new Map<string, Promise<{ file: XMLHttpRequestBodyInit }>>();

constructor({ client, matrixRoomId, livekitServiceUrl }: DriverOptions) {
super();
Expand Down Expand Up @@ -178,18 +197,37 @@ 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): Promise<void> {
if (!(await this.client.doesServerSupportUnstableFeature(DELAYED_EVENTS_FEATURE))) {
throw new Error('The chat server does not support delayed events');
}
await this.client.http.authedRequest(
'POST',
`/delayed_events/${encodeURIComponent(delayId)}`,
undefined,
{ action },
{ prefix: DELAYED_EVENTS_PREFIX }
);
}

public async cancelScheduledDelayedEvent(delayId: string): Promise<void> {
this.pendingLeaves.delete(delayId);
await this.client._unstable_updateDelayedEvent(delayId, 'cancel');
await this.updateDelayedEvent(delayId, 'cancel');
}

public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
await this.client._unstable_updateDelayedEvent(delayId, 'restart');
await this.updateDelayedEvent(delayId, 'restart');
}

public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
this.pendingLeaves.delete(delayId);
await this.client._unstable_updateDelayedEvent(delayId, 'send');
await this.updateDelayedEvent(delayId, 'send');
}

/**
Expand Down Expand Up @@ -369,10 +407,26 @@ class ElementCallWidgetDriver extends WidgetDriver {
if (!url) {
throw new Error('Only chat server media can be downloaded');
}
const known = this.downloads.get(contentUri);
if (known) {
return known;
}
const download = this.fetchMedia(url);
this.downloads.set(contentUri, download);
// A successful download is fetched afresh next time; a failed one stays failed for the call.
download.then(
() => this.downloads.delete(contentUri),
() => undefined
);
return download;
}

private async fetchMedia(url: string): Promise<{ file: XMLHttpRequestBodyInit }> {
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}`);
}
// Reading the body can still fail after the response arrived, e.g. when the connection drops.
return { file: await response.blob() };
}

Expand Down
69 changes: 69 additions & 0 deletions src/main/userMessaging/calls/quietDownloadErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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<typeof vi.fn<(...args: unknown[]) => 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);
});

it('stays until the last of two open calls releases, and a repeated release counts once', () => {
const first = quietDownloadErrors();
const second = quietDownloadErrors();
first();
first();
console.error(MESSAGE);
expect(sink).not.toHaveBeenCalled();
second();
expect(console.error).toBe(sink);
console.error(MESSAGE);
expect(sink).toHaveBeenCalledTimes(1);
});

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);

// Opening another call reuses the filter still in the chain instead of stacking a second one.
const again = quietDownloadErrors();
console.error(MESSAGE);
expect(sink).toHaveBeenCalledTimes(1);
again();
// Once the later wrapper is gone, the filter's own last release restores the original.
console.error = ours;
quietDownloadErrors()();
expect(console.error).toBe(sink);
});
});
Loading
Loading