Merge branch 'livekit' into fkwp/feature_add_delegation_info_to_legacy_jwt_request

This commit is contained in:
fkwp
2026-05-04 20:29:38 +02:00
57 changed files with 1474 additions and 1052 deletions

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true enable_registration: true
enable_registration_without_verification: true enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false report_stats: false
serve_server_wellknown: true serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true enable_registration: true
enable_registration_without_verification: true enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false report_stats: false
serve_server_wellknown: true serve_server_wellknown: true

View File

@@ -35,6 +35,14 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
} }
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
} }
# Synapse reverse proxy including .well-known/matrix/client # Synapse reverse proxy including .well-known/matrix/client
@@ -74,6 +82,14 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
} }
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
} }
# MatrixRTC reverse proxy # MatrixRTC reverse proxy

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true enable_registration: true
enable_registration_without_verification: true enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false report_stats: false
serve_server_wellknown: true serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true enable_registration: true
enable_registration_without_verification: true enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false report_stats: false
serve_server_wellknown: true serve_server_wellknown: true

View File

@@ -12,7 +12,6 @@
"feature_use_device_session_member_events": true "feature_use_device_session_member_events": true
}, },
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", "ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"sync_disconnect_grace_period_ms": 60000,
"matrix_rtc_session": { "matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000, "wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,

View File

@@ -3,7 +3,7 @@ networks:
services: services:
auth-service: auth-service:
image: ghcr.io/element-hq/lk-jwt-service:pr_171 image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always pull_policy: always
hostname: auth-server hostname: auth-server
environment: environment:
@@ -25,7 +25,7 @@ services:
- ecbackend - ecbackend
auth-service-1: auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:pr_171 image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always pull_policy: always
hostname: auth-server-1 hostname: auth-server-1
environment: environment:

View File

@@ -10,7 +10,7 @@
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
/> />
<title><%- brand %></title> <title><%- brand %></title>
<script> <script>

View File

@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0", "@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0", "@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.0.0", "@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3", "babel-plugin-transform-vite-meta-env": "^1.0.3",
@@ -132,7 +132,7 @@
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5", "typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0", "unique-names-generator": "^4.6.0",
"uuid": "^13.0.0", "uuid": "^14.0.0",
"vaul": "^1.0.0", "vaul": "^1.0.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0", "vite-plugin-generate-file": "^0.3.0",
@@ -153,7 +153,7 @@
"glob": "^10.5.0", "glob": "^10.5.0",
"qs": "^6.14.1", "qs": "^6.14.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"esbuild": "^0.27.7" "esbuild": "^0.28.0"
} }
}, },
"packageManager": "pnpm@10.33.0" "packageManager": "pnpm@10.33.0"

View File

@@ -7,11 +7,22 @@ Please see LICENSE in the repository root for full details.
*/ */
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
import { join } from "path";
import path from "node:path";
import { fileURLToPath } from "node:url";
const baseURL = process.env.USE_DOCKER const baseURL = process.env.USE_DOCKER
? "http://localhost:8080" ? "http://localhost:8080"
: "https://localhost:3000"; : "https://localhost:3000";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
"backend/dev_tls_local-ca.crt",
);
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */

View File

@@ -75,9 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible(); await expect(page.getByTestId("video").first()).toBeVisible();
}); });
// We skip this test for now as it appears the livekit does not let us test("Should show error screen if call creation is restricted", async ({
// detect and handle NotAllowed errors anymore. https://github.com/livekit/client-sdk-js/issues/1883
test.skip("Should show error screen if call creation is restricted", async ({
page, page,
browserName, browserName,
}) => { }) => {

View File

@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1 await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true }) .getByRole("button", { name: "Invite to this room", exact: true })
.click(); .click({
timeout: 10000,
});
await expect( await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }), ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible(); ).toBeVisible();

View File

@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
mobileTest( mobileTest(
"Test earpiece overlay in controlledAudioDevices mode", "Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => { async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
const { creatorPage, inviteLink } = asMobile; const { creatorPage, inviteLink } = asMobile;
// ======== // ========

View File

@@ -9,7 +9,9 @@ import { expect, test } from "@playwright/test";
test("When creator left, avoid reconnect to the same SFU", async ({ test("When creator left, avoid reconnect to the same SFU", async ({
browser, browser,
browserName,
}) => { }) => {
test.skip(browserName === "firefox", "Browser independent");
// Use reduce motion to disable animations that are making the tests a bit flaky // Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" }); const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage(); const creatorPage = await creatorContext.newPage();
@@ -91,8 +93,10 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// the creator leaves the call // the creator leaves the call
await creatorPage.getByTestId("incall_leave").click(); await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344 // https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU // The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1); expect(wsConnectionCount).toBe(1);
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
await guestCPage.waitForTimeout(6000);
expect(wsConnectionCount).toBe(1);
}); });

View File

@@ -115,8 +115,12 @@ test("One to One rejoin after improper leave does not crash EC", async ({
await guestPage.getByTestId("lobby_joinCall").click(); await guestPage.getByTestId("lobby_joinCall").click();
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media // We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
await expect(page.getByTestId("videoTile")).toHaveCount(3); await expect(page.getByTestId("videoTile")).toHaveCount(3, {
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2); timeout: 10000,
});
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
}); });
function isStickySend(url: string): boolean { function isStickySend(url: string): boolean {

View File

@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
async function expectVideoTilesCount(page: Page, count: number): Promise<void> { async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("videoTile")).toHaveCount(2); await expect(page.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await page.waitForTimeout(3000);
// No one should be waiting for media // No one should be waiting for media
await expect(page.getByText("Waiting for media...")).not.toBeVisible(); await expect(page.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying // There should be `count` video elements, visible and autoplaying
const videoElements = await page.locator("video").all(); await expect(page.locator("video")).toHaveCount(count);
expect(videoElements.length).toBe(count);
const blockDisplayCount = await page await expect(async () => {
.locator("video") const videoBlockCount = await page
.evaluateAll( .locator("video")
(videos: Element[]) => .evaluateAll(
videos.filter( (videos: Element[]) =>
(v: Element) => window.getComputedStyle(v).display === "block", videos.filter(
).length, (v: Element) => window.getComputedStyle(v).display === "block",
); ).length,
expect(blockDisplayCount).toBe(count); );
expect(videoBlockCount).toBe(count);
}).toPass({
timeout: 10000,
});
} }
export const SpaHelpers = { export const SpaHelpers = {

View File

@@ -0,0 +1,142 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createHmac } from "crypto";
/**
* Response from Synapse registration API
*/
export interface SynapseRegistrationResponse {
access_token: string;
user_id: string;
home_server: string;
device_id: string;
}
/**
* Utility class for interacting with Synapse Admin API
* This provides fast user registration without going through the UI
*
* @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html
*/
export class SynapseAdmin {
public constructor(
private baseUrl: string = "https://synapse.m.localhost",
private sharedSecret: string = "test_shared_secret_for_local_dev_only",
) {}
/**
* Register a user using the Synapse Admin API
* This is much faster than going through the UI registration flow
*
* @param username - The username (localpart) for the new user
* @param password - The password for the new user
* @param displayName - Optional display name (defaults to username)
* @param admin - Whether the user should be an admin (defaults to false)
* @returns Registration response containing access token and user ID
*/
public async registerUser(
username: string,
password: string,
displayName?: string,
admin: boolean = false,
): Promise<SynapseRegistrationResponse> {
// Get a nonce first
const nonce = await this.getNonce();
// Generate the HMAC
const mac = this.generateMac(username, password, admin, nonce);
// Make the registration request
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nonce,
username,
password,
displayname: displayName || username,
admin,
mac,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to register user ${username}: ${response.status} ${error}`,
);
}
return response.json();
}
/**
* Get a nonce for registration
* The nonce is required for the HMAC calculation
*
* @returns A nonce string
*/
private async getNonce(): Promise<string> {
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to get nonce: ${response.status} ${await response.text()}`,
);
}
const data = await response.json();
return data.nonce;
}
/**
* Generate HMAC for shared secret registration
* This is the authentication mechanism for the admin API
*
* @param username - The username
* @param password - The password
* @param admin - Whether the user is an admin
* @param nonce - The nonce from the server
* @returns The HMAC hex string
*/
private generateMac(
username: string,
password: string,
admin: boolean,
nonce: string,
): string {
const mac = createHmac("sha1", this.sharedSecret);
mac.update(nonce);
mac.update("\x00");
mac.update(username);
mac.update("\x00");
mac.update(password);
mac.update("\x00");
mac.update(admin ? "admin" : "notadmin");
return mac.digest("hex");
}
/**
* Create a new SynapseAdmin instance for a different homeserver
*
* @param baseUrl - The base URL of the homeserver
* @param sharedSecret - The shared secret (defaults to test secret)
* @returns A new SynapseAdmin instance
*/
public static forHomeserver(
baseUrl: string,
sharedSecret: string = "test_shared_secret_for_local_dev_only",
): SynapseAdmin {
return new SynapseAdmin(baseUrl, sharedSecret);
}
}

View File

@@ -26,8 +26,12 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
const florian = await addUser("floriant", HOST1); test.slow();
const timo = await addUser("timo", HOST2);
const [florian, timo] = await Promise.all([
addUser("florian", HOST1),
addUser("timo", HOST2),
]);
const roomName = "Call Room"; const roomName = "Call Room";
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
const frame = user.page const frame = user.page
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame(); .contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2); await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(3000);
// No one should be waiting for media // No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// There should be 2 video elements, visible and autoplaying // There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all(); const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2); expect(videoElements.length).toBe(2);
const blockDisplayCount = await frame await TestHelpers.expectVisibleVideoCount(frame, 2);
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
} }
// await florian.page.pause(); // await florian.page.pause();

View File

@@ -75,18 +75,11 @@ widgetTest(
await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 2 video elements, visible and autoplaying // There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all(); await expect(frame.locator("video")).toHaveCount(2, {
expect(videoElements.length).toBe(2); timeout: 10000,
});
const blockDisplayCount = await frame await TestHelpers.expectVisibleVideoCount(frame, 2);
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
} }
}, },
); );

View File

@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
widgetTest( widgetTest(
`Test swapping publisher from ${HOST1} to ${HOST2}`, `Test swapping publisher from ${HOST1} to ${HOST2}`,
async ({ addUser, browserName }) => { async ({ addUser, browserName }) => {
test.slow();
test.skip( test.skip(
browserName === "firefox", browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
@@ -65,40 +66,26 @@ widgetTest(
.contentFrame(); .contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2); await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready? // Wait for "Waiting for media..." to disappear (with timeout)
// Or it is too flaky :/ await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
await user.page.waitForTimeout(3000); timeout: 10000, // Maximum time to wait
// No one should be waiting for media });
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 2 video elements, visible and autoplaying // There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all(); await expect(frame.locator("video")).toHaveCount(2, {
expect(videoElements.length).toBe(2); timeout: 10000,
});
const blockDisplayCount = await frame await TestHelpers.expectVisibleVideoCount(frame, 2);
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
} }
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1) // now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat"); await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
await timo.page.waitForTimeout(3000); await timo.page.waitForTimeout(3000);
const blockDisplayCount = await timo.page
.locator('iframe[title="Element Call"]') await TestHelpers.expectVisibleVideoCount(
.contentFrame() timo.page.locator('iframe[title="Element Call"]').contentFrame(),
.locator("video") 2,
.evaluateAll( );
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
}, },
); );

View File

@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts"; import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Create and join a group call", async ({ addUser, browserName }) => { widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
// increase the timeouts, it is a long test and it is annoying to retry from the beginning for a single timeout.
test.slow();
test.skip( test.skip(
browserName === "firefox", browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
test.slow(); // We are registering multiple users here, give it more time const [valere, timo, robin, halfshot, florian] = await Promise.all([
addUser("Valere", HOST1),
const valere = await addUser("Valere", HOST1); addUser("Timo", HOST1),
const timo = await addUser("Timo", HOST1); addUser("Robin", HOST1),
const robin = await addUser("Robin", HOST1); addUser("Halfshot", HOST1),
const halfshot = await addUser("Halfshot", HOST1); addUser("florian", HOST1),
const florian = await addUser("florian", HOST1); ]);
const roomName = "Group Call Room"; const roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [ await TestHelpers.createRoom(roomName, valere.page, [
@@ -47,52 +50,55 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await TestHelpers.joinCallFromLobby(valere.page); await TestHelpers.joinCallFromLobby(valere.page);
for (const user of [timo, robin, halfshot, florian]) { await Promise.all(
await TestHelpers.joinCallInCurrentRoom(user.page); [timo, robin, halfshot, florian].map(async (user) => {
} await TestHelpers.joinCallInCurrentRoom(user.page);
}),
);
for (const user of [timo, robin, halfshot, florian]) { await Promise.all(
const frame = user.page [timo, robin, halfshot, florian].map(async (user) => {
.locator('iframe[title="Element Call"]') const frame = user.page
.contentFrame(); .locator('iframe[title="Element Call"]')
// No lobby, should start with video on .contentFrame();
await expect( await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }), frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible(); ).toBeVisible({
} timeout: 10000,
});
}),
);
// We should see 5 video tiles everywhere now // We should see 5 video tiles everywhere now
for (const user of [valere, timo, robin, halfshot, florian]) { await Promise.all(
const frame = user.page [valere, timo, robin, halfshot, florian].map(async (user) => {
.locator('iframe[title="Element Call"]') const frame = user.page
.contentFrame(); .locator('iframe[title="Element Call"]')
await expect(frame.getByTestId("videoTile")).toHaveCount(5); .contentFrame();
for (const participant of [valere, timo, robin, halfshot, florian]) { await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
// Check the names are correct timeout: 15000,
await expect(frame.getByText(participant.displayName)).toBeVisible(); });
}
// There is no other options than to wait for all media to be ready? await Promise.all(
// Or it is too flaky :/ [valere, timo, robin, halfshot, florian].map(async (user) => {
await user.page.waitForTimeout(5000); // Check the names are correct
// No one should be waiting for media await expect(frame.getByText(user.displayName)).toBeVisible();
await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); }),
// There should be 5 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
); );
expect(blockDisplayCount).toBe(5);
} // No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
// Use a bigger timeout here
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying
await expect(frame.locator("video")).toHaveCount(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
await TestHelpers.expectVisibleVideoCount(frame, 5);
}),
);
// Quickly test muting one participant to see it reflects and that our asserts works // Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page const florianFrame = florian.page
@@ -108,28 +114,16 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await expect(florianVideoButton).toHaveAccessibleName("Start video"); await expect(florianVideoButton).toHaveAccessibleName("Start video");
await expect(florianVideoButton).not.toBeChecked(); await expect(florianVideoButton).not.toBeChecked();
// wait a bit for the state to propagate
await valere.page.waitForTimeout(3000);
{ {
const frame = valere.page const frame = valere.page
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame(); .contentFrame();
const videoElements = await frame.locator("video").all(); await expect(frame.locator("video")).toHaveCount(5, {
expect(videoElements.length).toBe(5); timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
// out of 5 ONLY 4 are visible (display:block) !! // out of 5 ONLY 4 are visible (display:block) !!
// XXX we need to be better at our HTML markup and accessibility, it would make await TestHelpers.expectVisibleVideoCount(frame, 4);
// this kind of stuff way easier to test if we could look out for aria attributes.
expect(blockDisplayCount).toBe(4);
} }
}); });

View File

@@ -16,8 +16,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
test.slow();
const valere = await addUser("Valere", HOST1); const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom"; const callRoom = "CallRoom";

View File

@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
// check that the video is on // check that the video is on
await expect( await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }), frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible(); ).toBeVisible({
// Increase timeout, as this expect was flaky
timeout: 15000,
});
// Switch to the other room, the call should go to PIP // Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask"); await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
const frame = valere.page const frame = valere.page
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame(); .contentFrame();
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
const videoElements = await frame.locator("video").all(); const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(1);
const pipVideo = videoElements[0]; const pipVideo = videoElements[0];
await expect(pipVideo).toHaveCSS("object-fit", "cover"); await expect(pipVideo).toHaveCSS("object-fit", "cover");

View File

@@ -18,9 +18,11 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
test.slow(); // We are registering multiple users here, give it more time test.slow(); // We are registering multiple users here, give it more time
const alice = await addUser("Alice", HOST1); const [alice, bob, carol] = await Promise.all([
const bob = await addUser("Bob", HOST1); addUser("Alice", HOST1),
const carol = await addUser("Carol", HOST1); addUser("Bob", HOST1),
addUser("Carol", HOST1),
]);
const roomName = "Meeting Room"; const roomName = "Meeting Room";
await TestHelpers.createRoom(roomName, alice.page, [bob.mxId, carol.mxId]); await TestHelpers.createRoom(roomName, alice.page, [bob.mxId, carol.mxId]);
@@ -50,7 +52,7 @@ widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
// Expect 3 video tiles // Expect 3 video tiles
await expect(frame.locator("video")).toHaveCount(3, { await expect(frame.locator("video")).toHaveCount(3, {
timeout: 5000, timeout: 10000,
}); });
} }

View File

@@ -17,7 +17,7 @@ widgetTest.skip(
); );
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => { widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
test.slow(); // Triples the timeout test.slow();
const { brooks, whistler } = asWidget; const { brooks, whistler } = asWidget;
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
.locator('iframe[title="Element Call"]') .locator('iframe[title="Element Call"]')
.contentFrame() .contentFrame()
.getByTestId("incall_leave") .getByTestId("incall_leave")
.click(); .click({ timeout: 15000 });
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible(); await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible({
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible(); timeout: 10000,
});
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible({
timeout: 10000,
});
}); });

View File

@@ -10,9 +10,12 @@ import {
expect, expect,
type JSHandle, type JSHandle,
type Page, type Page,
type FrameLocator,
} from "@playwright/test"; } from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
const PASSWORD = "foobarbaz1!"; const PASSWORD = "foobarbaz1!";
export const HOST1 = "https://app.m.localhost/#/welcome"; export const HOST1 = "https://app.m.localhost/#/welcome";
@@ -26,14 +29,14 @@ export class TestHelpers {
voice: boolean = false, voice: boolean = false,
): Promise<void> { ): Promise<void> {
const buttonName = voice ? "Voice call" : "Video call"; const buttonName = voice ? "Voice call" : "Video call";
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
await page.getByRole("button", { name: buttonName }).click();
await expect( await page.getByRole("button", { name: buttonName }).click({
page.getByRole("menuitem", { name: "Element Call" }), timeout: 5000,
).toBeVisible(); });
await page.getByRole("menuitem", { name: "Element Call" }).click(); await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
} }
public static async joinCallFromLobby(page: Page): Promise<void> { public static async joinCallFromLobby(page: Page): Promise<void> {
@@ -51,15 +54,40 @@ export class TestHelpers {
.click(); .click();
} }
public static async joinCallInCurrentDM(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, true);
}
public static async joinCallInCurrentRoom( public static async joinCallInCurrentRoom(
page: Page, page: Page,
audioOnly: boolean = false, audioOnly: boolean = false,
): Promise<void> { ): Promise<void> {
// This is the header button that notifies about an ongoing call await this.joinCallInRoom(page, audioOnly, false);
const label = audioOnly ? "Voice call started" : "Video call started"; }
await expect(page.getByText(label)).toBeVisible();
await expect(page.getByRole("button", { name: "Join" })).toBeVisible(); public static async joinCallInRoom(
await page.getByRole("button", { name: "Join" }).click(); page: Page,
audioOnly: boolean = false,
isDM: boolean = false,
): Promise<void> {
// XXX This using the notification toast to join the room.
// Not the button in the header
await page.waitForTimeout(3000);
const label = isDM
? audioOnly
? "Incoming voice call"
: "Incoming video call"
: "Group call started";
await expect(page.getByText(label)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Join" }).click({
timeout: 5000,
});
} }
/** /**
@@ -74,43 +102,56 @@ export class TestHelpers {
clientHandle: JSHandle<MatrixClient>; clientHandle: JSHandle<MatrixClient>;
mxId: string; mxId: string;
}> { }> {
// Determine which homeserver to use based on the host
const synapseBaseUrl =
host === HOST2
? "https://synapse.othersite.m.localhost"
: "https://synapse.m.localhost";
// Register user via Synapse Admin API to speed things up
const synapseAdmin = SynapseAdmin.forHomeserver(synapseBaseUrl);
const credentials = await synapseAdmin.registerUser(
username,
PASSWORD,
username,
);
// STEP 2: Open browser and login
const userContext = await browser.newContext({ const userContext = await browser.newContext({
reducedMotion: "reduce", reducedMotion: "reduce",
}); });
const page = await userContext.newPage(); const page = await userContext.newPage();
await page.goto(host); await page.goto(host);
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username); await page.getByRole("link", { name: "Sign in" }).click({
await page.getByRole("textbox", { name: "Password", exact: true }).click(); timeout: 10000,
await page });
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD); await page.getByRole("textbox", { name: "Username" }).fill(username, {
await page.getByRole("textbox", { name: "Confirm password" }).click(); timeout: 10000,
await page });
.getByRole("textbox", { name: "Confirm password" }) await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
.fill(PASSWORD); timeout: 10000,
await page.getByRole("button", { name: "Register" }).click(); });
await page.getByRole("button", { name: "Sign in" }).click();
await expect( await expect(
page.getByRole("heading", { name: `Welcome ${username}` }), page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({ ).toBeVisible({
// Increase timeout as registration can be slow :/ // Increase timeout here :/ flaky
timeout: 15_000, timeout: 15000,
}); });
await this.maybeDismissBrowserNotSupportedToast(page); await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page); await this.maybeDismissServiceWorkerWarningToast(page);
await this.maybeDismissBackupChat(page);
await TestHelpers.setDevToolElementCallDevUrl(page); await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() => const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(), window.mxMatrixClientPeg.get(),
); );
const mxId = (await clientHandle.evaluate( const mxId = credentials.user_id;
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId }; return { page, clientHandle, mxId };
} }
@@ -152,6 +193,22 @@ export class TestHelpers {
} }
} }
private static async maybeDismissBackupChat(page: Page): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Back up your chats");
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "Dismiss" })
.click();
} catch {
// toast not visible, continue as normal
}
}
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> { public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
const toast = page const toast = page
.locator(".mx_Toast_toast") .locator(".mx_Toast_toast")
@@ -178,10 +235,14 @@ export class TestHelpers {
.getByRole("button", { name: "New conversation" }) .getByRole("button", { name: "New conversation" })
.click(); .click();
await page.getByRole("menuitem", { name: "New Room" }).click(); await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await page.getByRole("textbox", { name: "Name" }).fill(name); await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click(); await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible(); await expect(page.getByText("You created this room.")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Encryption enabled")).toBeVisible(); await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page); await TestHelpers.maybeDismissKeyBackupToast(page);
@@ -212,9 +273,12 @@ export class TestHelpers {
roomName: string, roomName: string,
page: Page, page: Page,
): Promise<void> { ): Promise<void> {
await expect(page.getByRole("option", { name: roomName })).toBeVisible(); await page.getByRole("option", { name: roomName }).click({
await page.getByRole("option", { name: roomName }).click(); timeout: 10000,
await page.getByRole("button", { name: "Accept" }).click(); });
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
await expect( await expect(
page.getByRole("main").getByRole("heading", { name: roomName }), page.getByRole("main").getByRole("heading", { name: roomName }),
@@ -234,8 +298,12 @@ export class TestHelpers {
page: Page, page: Page,
mode: RtcMode, mode: RtcMode,
): Promise<void> { ): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click(); await page.getByRole("button", { name: "Video call" }).click({
await page.getByRole("menuitem", { name: "Element Call" }).click(); timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode); await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click(); await page.getByRole("button", { name: "Close lobby" }).click();
@@ -331,4 +399,30 @@ export class TestHelpers {
timeout: 5000, timeout: 5000,
}); });
} }
public static async expectVisibleVideoCount(
frame: FrameLocator,
count: number,
): Promise<void> {
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
await expect
.poll(
async () => {
return await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) =>
window.getComputedStyle(v).display === "block",
).length,
);
},
{
timeout: 10000,
},
)
.toBe(count);
}
} }

View File

@@ -20,8 +20,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget; const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, true); await TestHelpers.startCallInCurrentRoom(brooks.page, true);
@@ -43,7 +41,7 @@ widgetTest(
).toBeVisible(); ).toBeVisible();
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible(); await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click(); await whistler.page.getByRole("button", { name: "Join" }).click();
await expect( await expect(
whistler.page.locator('iframe[title="Element Call"]'), whistler.page.locator('iframe[title="Element Call"]'),
@@ -113,8 +111,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget; const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false); await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -136,7 +132,7 @@ widgetTest(
).toBeVisible(); ).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click(); await whistler.page.getByRole("button", { name: "Join" }).click();
await expect( await expect(
whistler.page.locator('iframe[title="Element Call"]'), whistler.page.locator('iframe[title="Element Call"]'),
@@ -200,8 +196,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled", "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
); );
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget; const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false); await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -223,7 +217,7 @@ widgetTest(
).toBeVisible(); ).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible(); await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Decline" }).click(); await whistler.page.getByRole("button", { name: "Ignore" }).click();
await expect( await expect(
whistler.page.locator('iframe[title="Element Call"]'), whistler.page.locator('iframe[title="Element Call"]'),

1446
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,9 @@
.bar { .bar {
block-size: 64px;
flex-shrink: 0; flex-shrink: 0;
} }
.bar > header { .bar > header {
position: absolute; position: sticky;
inset-inline: 0; inset-inline: 0;
inset-block-start: 0; inset-block-start: 0;
block-size: 64px; block-size: 64px;

View File

@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
align-items: center; align-items: center;
user-select: none; user-select: none;
flex-shrink: 0; flex-shrink: 0;
padding-inline: var(--inline-content-inset); padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
padding-top: env(safe-area-inset-top);
} }
.nav { .nav {

View File

@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
return ( return (
<Drawer.Root <Drawer.Root
open={open} open={open}
// This autofocus is a custom vault property and not the
// standard HTML autofocus attribute.
// It makes the Drawer.Root behave like the `DialogRoot`
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined} dismissible={onDismiss !== undefined}
> >

View File

@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div> <div>
<Button <Button
onClick={() => showFullModal("audio")} onClick={() => showFullModal("audio")}
size="sm" size="md"
kind="tertiary" kind="tertiary"
Icon={MicOnSolidIcon} Icon={MicOnSolidIcon}
> >
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div> <div>
<Button <Button
onClick={() => showFullModal("video")} onClick={() => showFullModal("video")}
size="sm" size="md"
kind="tertiary" kind="tertiary"
Icon={VideoCallSolidIcon} Icon={VideoCallSolidIcon}
> >

View File

@@ -32,7 +32,7 @@ import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean; enabled: boolean;
size?: "sm" | "lg"; size?: "md" | "lg";
} }
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => { export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
@@ -58,7 +58,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean; enabled: boolean;
size?: "sm" | "lg"; size?: "md" | "lg";
} }
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => { export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
@@ -84,7 +84,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean; enabled: boolean;
size: "sm" | "lg"; size: "md" | "lg";
} }
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
@@ -111,7 +111,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
}; };
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> { interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg"; size?: "md" | "lg";
} }
export const EndCallButton: FC<EndCallButtonProps> = ({ export const EndCallButton: FC<EndCallButtonProps> = ({
@@ -134,7 +134,7 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
}; };
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> { interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg"; size?: "md" | "lg";
loudspeakerModeEnabled: boolean; loudspeakerModeEnabled: boolean;
} }
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({ export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
@@ -195,7 +195,7 @@ export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
}; };
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> { interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg"; size?: "md" | "lg";
/** If this buttons should be setup to be used in the app bar */ /** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow"; showForScreenWidth?: "wide" | "narrow";
} }

View File

@@ -15,7 +15,7 @@ export const InviteButton: FC<
> = (props) => { > = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}> <Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
{t("action.invite")} {t("action.invite")}
</Button> </Button>
); );

View File

@@ -173,7 +173,7 @@ export interface ReactionData {
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
reactionData: ReactionData; reactionData: ReactionData;
identifier: string; identifier: string;
size?: "sm" | "lg"; size?: "md" | "lg";
/** List of participants raising their hand */ /** List of participants raising their hand */
} }

View File

@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_bb_" aria-labelledby="_r_bb_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_7m_" aria-labelledby="_r_7m_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_36_" aria-labelledby="_r_36_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
aria-expanded="true" aria-expanded="true"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_0_" aria-labelledby="_r_0_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_1j_" aria-labelledby="_r_1j_"
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"

View File

@@ -14,7 +14,11 @@ Please see LICENSE in the repository root for full details.
grid-template-areas: ". buttons layout"; grid-template-areas: ". buttons layout";
align-items: center; align-items: center;
gap: var(--cpd-space-3x); gap: var(--cpd-space-3x);
padding: var(--cpd-space-10x) var(--cpd-space-6x); /* Ensure that footer lies within the safe area */
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
padding-block: var(--cpd-space-10x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
@@ -118,13 +122,15 @@ Once we exceed 500 we hide everything except the buttons.
@media (max-height: 800px) { @media (max-height: 800px) {
.footer { .footer {
padding-block: var(--cpd-space-8x); padding-block: var(--cpd-space-8x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
} }
} }
@media (max-height: 400px) { @media (max-height: 400px) {
.footer { .footer {
padding-block: var(--cpd-space-4x); padding-block: var(--cpd-space-4x)
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
} }
} }
@@ -140,7 +146,9 @@ Once we exceed 500 we hide everything except the buttons.
} }
.footer { .footer {
padding-block-start: var(--cpd-space-3x); padding-block-start: var(--cpd-space-3x);
padding-block-end: var(--cpd-space-2x); padding-block-end: calc(
env(safe-area-inset-bottom) + var(--cpd-space-2x)
);
} }
} }
} }

View File

@@ -157,6 +157,8 @@ export const UnavailableMediaDevices: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined, toggleAudio: undefined,
toggleVideo: undefined, toggleVideo: undefined,
audioOutputSwitcher: undefined, audioOutputSwitcher: undefined,

View File

@@ -101,7 +101,7 @@ export const CallFooter: FC<FooterProps> = ({
tileStoreGeneration, tileStoreGeneration,
}) => { }) => {
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "sm" : "lg"; const buttonSize = asPip ? "md" : "lg";
const showSettingsButton = const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls; openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls; const showLayoutSwitcher = !asPip && !hideControls;

View File

@@ -162,6 +162,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
server_name: string; server_name: string;
}; };
}; };
sync_disconnect_grace_period_ms: number;
ssla: string; ssla: string;
} }

View File

@@ -266,6 +266,20 @@ export function Grid<
}, []), }, []),
useCallback(() => window.innerHeight, []), useCallback(() => window.innerHeight, []),
); );
const orientation = useSyncExternalStore(
useCallback((onChange) => {
// Support for the change event is experimental
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
return (): void =>
(screen as unknown as EventTarget).removeEventListener?.(
"change",
onChange,
);
}, []),
useCallback(() => window.innerHeight, []),
);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] = const [visibleTilesCallback, setVisibleTilesCallback] =
@@ -336,10 +350,10 @@ export function Grid<
} }
return result; return result;
// The rects may change due to the grid resizing or updating to a new // The rects may change due to the grid resizing, changing orientation, or
// generation, but eslint can't statically verify this // updating to a new generation, but eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]); }, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
// The height of the portion of the grid visible at any given time // The height of the portion of the grid visible at any given time
const visibleHeight = useMemo( const visibleHeight = useMemo(

View File

@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
position: absolute; position: absolute;
inline-size: 404px; inline-size: 404px;
block-size: 233px; block-size: 233px;
inset-block: 0; /* Ensure that spotlight tile lies within the safe area */
inset-inline: var(--cpd-space-3x); inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
} }
.fixed > .slot[data-block-alignment="start"] { .fixed > .slot[data-block-alignment="start"] {

View File

@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
position: absolute; position: absolute;
inline-size: 135px; inline-size: 135px;
block-size: 160px; block-size: 160px;
inset: var(--cpd-space-4x); /* Ensure that PiP lies within the safe area */
inset: calc(env(safe-area-inset-top) + var(--cpd-space-4x))
var(--content-inset-right)
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x))
var(--content-inset-left);
} }
@media (min-width: 600px) { @media (min-width: 600px) {

View File

@@ -37,10 +37,20 @@ layer(compound);
--cpd-color-border-accent: var(--cpd-color-green-800); --cpd-color-border-accent: var(--cpd-color-green-800);
/* The distance to inset non-full-width content from the edge of the window /* The distance to inset non-full-width content from the edge of the window
along the inline axis. This ramps up from 16px for typical mobile windows, to along the inline axis. This ramps up from 16px for typical mobile windows, to
96px for typical desktop windows. */ 96px for typical desktop windows, and accounts for the safe area. */
--inline-content-inset: min( --content-inset-left: calc(
var(--cpd-space-24x), env(safe-area-inset-left) +
max(var(--cpd-space-4x), calc((100vw - 900px) / 3)) min(
var(--cpd-space-24x),
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
)
);
--content-inset-right: calc(
env(safe-area-inset-right) +
min(
var(--cpd-space-24x),
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
)
); );
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a; --big-drop-shadow: 0px 0px 24px 0px #1b1d221a;

View File

@@ -113,7 +113,7 @@ export const AvatarInputField: FC<Props> = ({
iconOnly iconOnly
Icon={EditIcon} Icon={EditIcon}
kind="tertiary" kind="tertiary"
size="sm" size="md"
aria-label={t("action.edit")} aria-label={t("action.edit")}
/> />
} }
@@ -136,7 +136,7 @@ export const AvatarInputField: FC<Props> = ({
iconOnly iconOnly
Icon={EditIcon} Icon={EditIcon}
kind="tertiary" kind="tertiary"
size="sm" size="md"
aria-label={t("action.edit")} aria-label={t("action.edit")}
onClick={onSelectUpload} onClick={onSelectUpload}
/> />

View File

@@ -57,7 +57,8 @@ Please see LICENSE in the repository root for full details.
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-inline: var(--inline-content-inset); padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
} }
.logo { .logo {

View File

@@ -30,7 +30,7 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
<Text>{t("handset.overlay_description")}</Text> <Text>{t("handset.overlay_description")}</Text>
<Button <Button
kind="primary" kind="primary"
size="sm" size="md"
onClick={() => { onClick={() => {
onBackToVideoPressed?.(); onBackToVideoPressed?.();
}} }}

View File

@@ -227,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
const toggleVideo = useBehavior(muteStates.video.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
containerRef1,
toggleAudio, toggleAudio,
toggleVideo, toggleVideo,
setAudioEnabled, setAudioEnabled,

View File

@@ -47,6 +47,7 @@ import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter"; import { CallFooter } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -91,6 +92,11 @@ export const LobbyView: FC<Props> = ({
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
const openSettings = useCallback( const openSettings = useCallback(
() => setSettingsModalOpen(true), () => setSettingsModalOpen(true),
[setSettingsModalOpen], [setSettingsModalOpen],
@@ -209,7 +215,7 @@ export const LobbyView: FC<Props> = ({
className={classNames(styles.join, { className={classNames(styles.join, {
[styles.wait]: waitingForInvite, [styles.wait]: waitingForInvite,
})} })}
size={waitingForInvite ? "sm" : "lg"} size={waitingForInvite ? "md" : "lg"}
disabled={waitingForInvite} disabled={waitingForInvite}
onClick={() => { onClick={() => {
if (!waitingForInvite) onEnter(); if (!waitingForInvite) onEnter();

View File

@@ -6,7 +6,8 @@ Please see LICENSE in the repository root for full details.
*/ */
.preview { .preview {
margin-inline: var(--inline-content-inset); margin-left: var(--content-inset-left);
margin-right: var(--content-inset-right);
min-block-size: 0; min-block-size: 0;
block-size: 50vh; block-size: 50vh;
border-radius: var(--cpd-space-4x); border-radius: var(--cpd-space-4x);
@@ -80,6 +81,7 @@ video.mirror {
} }
.buttonBar { .buttonBar {
padding-inline: var(--inline-content-inset); padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
} }
} }

View File

@@ -134,7 +134,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
You were disconnected from the call. You were disconnected from the call.
</p> </p>
<button <button
class="_button_13vu4_8" class="_button_1nw83_8"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -143,7 +143,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
Reconnect Reconnect
</button> </button>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -297,7 +297,7 @@ exports[`should have a close button in widget mode 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_13vu4_8" class="_button_1nw83_8"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -451,7 +451,7 @@ exports[`should render the error page with link back to home 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -605,7 +605,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT). The server is not configured to work with Element Call. Please contact your server admin (Domain: example.com, Error Code: MISSING_MATRIX_RTC_TRANSPORT).
</p> </p>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -754,7 +754,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
You were disconnected from the call. You were disconnected from the call.
</p> </p>
<button <button
class="_button_13vu4_8" class="_button_1nw83_8"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -763,7 +763,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
Reconnect Reconnect
</button> </button>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -912,7 +912,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+. Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.
</p> </p>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"
@@ -1061,7 +1061,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists. The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.
</p> </p>
<button <button
class="_button_13vu4_8 homeLink" class="_button_1nw83_8 homeLink"
data-kind="tertiary" data-kind="tertiary"
data-size="lg" data-size="lg"
role="button" role="button"

View File

@@ -147,9 +147,9 @@ exports[`InCallView > rendering > renders 1`] = `
Only works while using app Only works while using app
</p> </p>
<button <button
class="_button_13vu4_8" class="_button_1nw83_8"
data-kind="primary" data-kind="primary"
data-size="sm" data-size="md"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@@ -305,7 +305,7 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<button <button
aria-labelledby="_r_d_" aria-labelledby="_r_d_"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
data-testid="settings-bottom-center" data-testid="settings-bottom-center"
@@ -329,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_i_" aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_mute" data-testid="incall_mute"
@@ -353,7 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_n_" aria-labelledby="_r_n_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_videomute" data-testid="incall_videomute"
@@ -375,7 +375,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button> </button>
<button <button
aria-labelledby="_r_s_" aria-labelledby="_r_s_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110" class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_leave" data-testid="incall_leave"

View File

@@ -6,13 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { ClientEvent, SyncState } from "matrix-js-sdk"; import { ClientEvent, SyncState } from "matrix-js-sdk";
import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc"; import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../../ObservableScope"; import { ObservableScope } from "../../ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected"; import { createHomeserverConnected$ } from "./HomeserverConnected";
import { testScope, withTestScheduler } from "../../../utils/test";
/** /**
* Minimal stub of a Matrix client sufficient for our tests: * Minimal stub of a Matrix client sufficient for our tests:
@@ -203,101 +204,60 @@ describe("createHomeserverConnected$", () => {
}); });
describe("createHomeserverConnected$ - Grace Period", () => { describe("createHomeserverConnected$ - Grace Period", () => {
let scope: ObservableScope; const GRACE_PERIOD = 5;
let client: MockMatrixClient;
let session: MockMatrixRTCSession;
const GRACE_PERIOD = 5000;
beforeEach(() => { function marbleTest(
vi.useFakeTimers(); syncStateMarbles: string,
scope = new ObservableScope(); expectedConnectedMarbles: string,
// Initialize with values that satisfy the "Connected" condition ): void {
client = new MockMatrixClient(SyncState.Syncing); withTestScheduler(({ behavior, schedule, expectObservable }) => {
session = new MockMatrixRTCSession({ const syncState$ = behavior(syncStateMarbles, {
membershipStatus: Status.Connected, s: SyncState.Syncing,
probablyLeft: false, e: SyncState.Error,
});
const client = new MockMatrixClient(syncState$.value);
schedule(syncStateMarbles, {
s: () => client.setSyncState(SyncState.Syncing),
e: () => client.setSyncState(SyncState.Error),
});
const session = new MockMatrixRTCSession({
membershipStatus: Status.Connected,
probablyLeft: false,
});
const hsConnected = createHomeserverConnected$(
testScope(),
client,
session,
GRACE_PERIOD,
);
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
y: true,
n: false,
});
}); });
}); }
afterEach(() => {
scope.end();
vi.useRealTimers();
});
it("respects gracePeriodMs: stays true during grace period and flips false after", () => { it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
const hsConnected = createHomeserverConnected$( // - Initial state: Everything is connected
scope, // - Sync error occurs -> should remain connected due to grace period
client, // - After grace period, not connected
session, marbleTest("se", "y-----n");
GRACE_PERIOD, // If the sync error takes longer to occur, it should take equally long for
); // the connection state to change
marbleTest("s--e", "y-------n");
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
// Initial state: Everything is connected
expect(hsConnected.combined$.value).toBe(true);
// 1. Sync loses connection -> should remain TRUE due to grace period
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(true);
// 2. Fast forward time (just before expiration)
vi.advanceTimersByTime(GRACE_PERIOD - 1);
expect(hsConnected.combined$.value).toBe(true);
// 3. Fast forward time (expiration)
vi.advanceTimersByTime(1);
expect(hsConnected.combined$.value).toBe(false);
}); });
it("recovers immediately if sync returns during grace period", () => { it("recovers immediately if sync returns during grace period", () => {
const hsConnected = createHomeserverConnected$( // - Initial state: Connected
scope, // - Sync error occurs
client, // - Sync recovers BEFORE the grace period expires
session, // - Connection state remains constant
GRACE_PERIOD, marbleTest("se--s", "y");
);
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
// Initial state: Connected
expect(hsConnected.combined$.value).toBe(true);
// 1. Sync error occurs
client.setSyncState(SyncState.Error);
vi.advanceTimersByTime(GRACE_PERIOD / 2);
expect(hsConnected.combined$.value).toBe(true);
// 2. Sync recovers BEFORE the grace period expires
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(true);
// 3. Fast forward the remaining time -> should stay TRUE
vi.advanceTimersByTime(GRACE_PERIOD);
expect(hsConnected.combined$.value).toBe(true);
}); });
it("flips to true IMMEDIATELY even if a grace period was pending", () => { it("flips to true IMMEDIATELY even if a grace period was pending", () => {
const hsConnected = createHomeserverConnected$( // - Initial error: connection eventually flips to false
scope, // - Back to Syncing -> Must be connected immediately (synchronously)
client, marbleTest("e-----s", "y----ny");
session,
GRACE_PERIOD,
);
session.setMembershipStatus(Status.Connected);
session.setProbablyLeft(false);
// 1. Initial error: wait until it flips to false
client.setSyncState(SyncState.Error);
expect(hsConnected.combined$.value).toBe(true);
vi.advanceTimersByTime(GRACE_PERIOD + 1);
expect(hsConnected.combined$.value).toBe(false);
// 2. Back to Syncing -> Must be TRUE immediately (synchronously)
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(true);
}); });
}); });

View File

@@ -64,8 +64,7 @@ export function createHomeserverConnected$(
gracePeriodMs?: number, gracePeriodMs?: number,
): HomeserverConnected { ): HomeserverConnected {
// Get grace period from parameter or config (default 10000ms) // Get grace period from parameter or config (default 10000ms)
const graceMs = const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms ?? 10000;
const syncing$ = ( const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]> fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
@@ -86,7 +85,7 @@ export function createHomeserverConnected$(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe( fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown), map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
), ),
Status.Unknown, matrixRTCSession.membershipStatus ?? Status.Unknown,
); );
const membershipConnected$ = rtsSession$.pipe( const membershipConnected$ = rtsSession$.pipe(

View File

@@ -33,6 +33,12 @@ Please see LICENSE in the repository root for full details.
--media-view-fg-inset: 10px; --media-view-fg-inset: 10px;
} }
.maximised .item {
/* Ensure that foreground elements lie within the safe area */
--media-view-fg-inset: 10px calc(env(safe-area-inset-right) + 10px) 10px
calc(env(safe-area-inset-left) + 10px);
}
.item.snap { .item.snap {
scroll-snap-align: start; scroll-snap-align: start;
} }

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { type FC, useRef, useState } from "react"; import { type FC, useState } from "react";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -39,9 +39,7 @@ const TestComponent: FC<TestComponentProps> = ({
initialModalOpen = false, initialModalOpen = false,
}) => { }) => {
const [modalOpen, setModalOpen] = useState(initialModalOpen); const [modalOpen, setModalOpen] = useState(initialModalOpen);
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
ref,
() => {}, () => {},
() => {}, () => {},
setAudioEnabled, setAudioEnabled,
@@ -49,8 +47,11 @@ const TestComponent: FC<TestComponentProps> = ({
toggleHandRaised, toggleHandRaised,
); );
return ( return (
<div ref={ref}> <>
<Button onClick={onButtonClick}>TEST</Button> <div id={initialModalOpen ? "root" : undefined}>
<Button onClick={onButtonClick}>TEST</Button>
</div>
{/*// modal lives outside of the root*/}
{modalOpen && ( {modalOpen && (
<dialog <dialog
open open
@@ -64,7 +65,7 @@ const TestComponent: FC<TestComponentProps> = ({
<button>InModalButton</button> <button>InModalButton</button>
</dialog> </dialog>
)} )}
</div> </>
); );
}; };
@@ -164,12 +165,13 @@ test("unmuting happens in place of the default action", async () => {
// container element that can be interactive and receive focus / keydown // container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job. // events. <video> is kind of a weird choice, but it'll do the job.
render( render(
<video <div id="root">
tabIndex={0} <video
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())} tabIndex={0}
> onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
<TestComponent setAudioEnabled={() => {}} /> />
</video>, <TestComponent setAudioEnabled={() => {}} />,
</div>,
); );
await user.tab(); // Focus the <video> await user.tab(); // Focus the <video>

View File

@@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type RefObject, useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { useEventTarget } from "./useEvents"; import { useEventTarget } from "./useEvents";
import { import {
@@ -18,22 +19,61 @@ import {
* Determines whether focus is in the same part of the tree as the given * Determines whether focus is in the same part of the tree as the given
* element (specifically, if the element or an ancestor of it is focused). * element (specifically, if the element or an ancestor of it is focused).
*/ */
const mayReceiveKeyEvents = (e: HTMLElement): boolean => { const mayReceiveKeyEvents = (): boolean => {
const focusedElement = document.activeElement; const root = document.getElementById("root");
return focusedElement !== null && focusedElement.contains(e); if (root === null) {
logger.warn(
"[mayReceiveKeyEvents] Root element not found, always allow keyboard shortcuts (m,v,esc...)",
);
return true;
}
const focusElement = document.activeElement;
const nothingInFocus = focusElement === null;
const focusOnBody = focusElement === document.body;
const noPrimaryFocus =
nothingInFocus || root.contains(focusElement) || focusOnBody;
logger.warn(
`[mayReceiveKeyEvents] nothingInFocus ${nothingInFocus}, focusOnBody ${focusOnBody}, noPrimaryFocus ${noPrimaryFocus}`,
);
// Only if we do not have a primary focus we allow keyboard shortcut events.
return noPrimaryFocus;
};
/**
* Only do push to talk behavior if the active element is not a button or button like.
*/
const mayReceiveSpaceKeyEvents = (): boolean => {
const activeElement = document.activeElement;
if (activeElement === null) return true;
return activeElement.tagName.toLowerCase() !== "button";
}; };
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries( const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]), ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
); );
/**
* This hook sets up gloabl keyboard shortcuts. It will filter for keyboard presses that should be ignored due to user
* currently focussing on a modal.
* This is achieved by using the fact, that all modal inputs are outside the #root element and use react portals to get rendered.
* The following shortcuts are auspported (optional):
* @param toggleAudio - triggered on (m)
* @param toggleVideo - triggered on (v)
* @param setAudioEnabled - push to talk behavior controlled via (space)
* @param sendReaction - triggered on (1,2,3,...)
* @param toggleHandRaised - triggered on (h)
* Additionally this method listens to the (escape) key to trigger the onBackButtonPressed callback, which is used to navigate to pip in the native app.
*
* Note: This function incorrectly assumes that there is a camera and microphone, which is not always the case.
*/
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
export function useCallViewKeyboardShortcuts( export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>,
toggleAudio: (() => void) | null, toggleAudio: (() => void) | null,
toggleVideo: (() => void) | null, toggleVideo: (() => void) | null,
setAudioEnabled: ((enabled: boolean) => void) | null, setAudioEnabled: ((enabled: boolean) => void) | null,
sendReaction: (reaction: ReactionOption) => void, sendReaction: ((reaction: ReactionOption) => void) | null,
toggleHandRaised: () => void, toggleHandRaised: (() => void) | null,
): void { ): void {
const spacebarHeld = useRef(false); const spacebarHeld = useRef(false);
@@ -45,8 +85,8 @@ export function useCallViewKeyboardShortcuts(
"keydown", "keydown",
useCallback( useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (focusElement.current === null) return; logger.info("Keydown event", event);
if (!mayReceiveKeyEvents(focusElement.current)) return; if (!mayReceiveKeyEvents()) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return; return;
@@ -56,7 +96,7 @@ export function useCallViewKeyboardShortcuts(
} else if (event.key === "v") { } else if (event.key === "v") {
event.preventDefault(); event.preventDefault();
toggleVideo?.(); toggleVideo?.();
} else if (event.key === " ") { } else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
event.preventDefault(); event.preventDefault();
if (!spacebarHeld.current) { if (!spacebarHeld.current) {
spacebarHeld.current = true; spacebarHeld.current = true;
@@ -64,16 +104,16 @@ export function useCallViewKeyboardShortcuts(
} }
} else if (event.key === "h") { } else if (event.key === "h") {
event.preventDefault(); event.preventDefault();
toggleHandRaised(); toggleHandRaised?.();
} else if (KeyToReactionMap[event.key]) { } else if (KeyToReactionMap[event.key]) {
event.preventDefault(); event.preventDefault();
sendReaction(KeyToReactionMap[event.key]); sendReaction?.(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
logger.info("Escape key pressed, triggering onBackButtonPressed");
window.controls.onBackButtonPressed?.(); window.controls.onBackButtonPressed?.();
} }
}, },
[ [
focusElement,
toggleVideo, toggleVideo,
toggleAudio, toggleAudio,
setAudioEnabled, setAudioEnabled,
@@ -92,15 +132,13 @@ export function useCallViewKeyboardShortcuts(
"keyup", "keyup",
useCallback( useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (focusElement.current === null) return; if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
if (!mayReceiveKeyEvents(focusElement.current)) return;
if (event.key === " ") { if (event.key === " ") {
spacebarHeld.current = false; spacebarHeld.current = false;
setAudioEnabled?.(false); setAudioEnabled?.(false);
} }
}, },
[focusElement, setAudioEnabled], [setAudioEnabled],
), ),
); );