Skip to content
Merged
23 changes: 22 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,7 @@ const makeClient = () =>
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';
Expand Down Expand Up @@ -289,6 +289,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 @@ -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);
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
162 changes: 152 additions & 10 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,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: { authedRequest: vi.fn(async () => ({})) },
};
};

Expand Down Expand Up @@ -264,14 +264,52 @@ 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.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 @@ -446,9 +484,7 @@ describe('ElementCallWidgetDriver — host behaviour', () => {
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.objectContaining({ headers: { Authorization: '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.
Expand All @@ -472,6 +508,112 @@ 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<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('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 () => {
const timeout = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
const fetchMock = vi.fn(
(_: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason));
})
);
vi.stubGlobal('fetch', fetchMock);
try {
const pending = driverFor(makeClient()).downloadFile('mxc://matrix.example.org/stuck');
expect(timeoutSpy).toHaveBeenCalledWith(20_000);
timeout.abort(new DOMException('timed out', 'TimeoutError'));
await expect(pending).rejects.toThrow('timed out');
} finally {
timeoutSpy.mockRestore();
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
87 changes: 72 additions & 15 deletions src/main/userMessaging/calls/ElementCallWidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ 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';
/** 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;
Expand Down Expand Up @@ -88,8 +95,15 @@ 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>;
http: {
authedRequest(
method: 'POST',
path: string,
queryParams: undefined,
body: { action: DelayedEventAction },
opts: { prefix: string }
): Promise<unknown>;
};
};

type DriverOptions = {
Expand Down Expand Up @@ -119,6 +133,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 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<string, Promise<{ file: XMLHttpRequestBodyInit }>>();

constructor({ client, matrixRoomId, livekitServiceUrl }: DriverOptions) {
super();
Expand Down Expand Up @@ -178,18 +199,34 @@ 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> {
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 All @@ -203,15 +240,12 @@ class ElementCallWidgetDriver extends WidgetDriver {
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(() => {
fetch(`${this.client.baseUrl}${DELAYED_EVENTS_PREFIX}/delayed_events/${encodeURIComponent(delayId)}`, {
method: 'POST',
keepalive: true,
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'send' }),
}).catch(() => {
// The page is going away; a leave that does not arrive still fires when its delay runs out.
});
}
Expand Down Expand Up @@ -369,10 +403,33 @@ 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()}` } });
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 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 response = await fetch(url, {
headers: { Authorization: `Bearer ${this.client.getAccessToken()}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
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
Loading
Loading