Merge branch 'livekit' into toger5/contribut-issue-first

This commit is contained in:
Timo K
2026-05-05 14:39:52 +02:00
58 changed files with 1737 additions and 975 deletions

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: 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
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: 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
serve_server_wellknown: true

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -34,6 +34,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
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;
}
error_page 500 502 503 504 /50x.html;
@@ -76,6 +83,14 @@ server {
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;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: 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
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: 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
serve_server_wellknown: true

View File

@@ -62,7 +62,10 @@ services:
- 7882:7882/tcp
- 50100-50200:50100-50200/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -82,7 +85,10 @@ services:
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -164,6 +170,8 @@ services:
- "8448:8448"
extra_hosts:
- "host.docker.internal:host-gateway"
- "auth-server:127.0.0.1"
- "auth-server-1:127.0.0.1"
depends_on:
- synapse
networks:

View File

@@ -10,7 +10,7 @@
<meta
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>
<script>

View File

@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@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",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
@@ -132,7 +132,7 @@
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"vaul": "^1.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
@@ -153,7 +153,7 @@
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1",
"esbuild": "^0.27.7"
"esbuild": "^0.28.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 { join } from "path";
import path from "node:path";
import { fileURLToPath } from "node:url";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "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.
*/

View File

@@ -75,9 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible();
});
// We skip this test for now as it appears the livekit does not let us
// 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 ({
test("Should show error screen if call creation is restricted", async ({
page,
browserName,
}) => {

View File

@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
.click({
timeout: 10000,
});
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
@@ -104,6 +106,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
await ewPage1.getByRole("dialog").getByRole("textbox").click();
await ewPage1.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await expect(
@@ -126,6 +129,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
await TestHelpers.dismissInviteUnknownUserModalDM(ewPage1);
// Wait and send the first message to create the DM
await expect(

View File

@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
mobileTest(
"Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
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 ({
browser,
browserName,
}) => {
test.skip(browserName === "firefox", "Browser independent");
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
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
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
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();
// 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(guestPage.getByTestId("videoTile")).toHaveCount(2);
await expect(page.getByTestId("videoTile")).toHaveCount(3, {
timeout: 10000,
});
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
});
function isStickySend(url: string): boolean {

View File

@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
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
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
const videoElements = await page.locator("video").all();
expect(videoElements.length).toBe(count);
// There should be `count` video elements, visible and autoplaying
await expect(page.locator("video")).toHaveCount(count);
const blockDisplayCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(count);
await expect(async () => {
const videoBlockCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(videoBlockCount).toBe(count);
}).toPass({
timeout: 10000,
});
}
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",
);
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
test.slow();
const [florian, timo] = await Promise.all([
addUser("florian", HOST1),
addUser("timo", HOST2),
]);
const roomName = "Call Room";
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.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
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
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// await florian.page.pause();

View File

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

View File

@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
widgetTest(
`Test swapping publisher from ${HOST1} to ${HOST2}`,
async ({ addUser, browserName }) => {
test.slow();
test.skip(
browserName === "firefox",
"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();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// 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
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// Wait for "Waiting for media..." to disappear (with timeout)
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000, // Maximum time to wait
});
// There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
await timo.page.waitForTimeout(3000);
const blockDisplayCount = await timo.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
2,
);
},
);

View File

@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
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(
browserName === "firefox",
"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 = await addUser("Valere", HOST1);
const timo = await addUser("Timo", HOST1);
const robin = await addUser("Robin", HOST1);
const halfshot = await addUser("Halfshot", HOST1);
const florian = await addUser("florian", HOST1);
const [valere, timo, robin, halfshot, florian] = await Promise.all([
addUser("Valere", HOST1),
addUser("Timo", HOST1),
addUser("Robin", HOST1),
addUser("Halfshot", HOST1),
addUser("florian", HOST1),
]);
const roomName = "Group Call Room";
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);
for (const user of [timo, robin, halfshot, florian]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
await TestHelpers.joinCallInCurrentRoom(user.page);
}),
);
for (const user of [timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// No lobby, should start with video on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible({
timeout: 10000,
});
}),
);
// We should see 5 video tiles everywhere now
for (const user of [valere, timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
for (const participant of [valere, timo, robin, halfshot, florian]) {
// Check the names are correct
await expect(frame.getByText(participant.displayName)).toBeVisible();
}
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
timeout: 15000,
});
// There is no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(5000);
// No one should be waiting for media
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,
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
// Check the names are correct
await expect(frame.getByText(user.displayName)).toBeVisible();
}),
);
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
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).not.toBeChecked();
// wait a bit for the state to propagate
await valere.page.waitForTimeout(3000);
{
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 10000,
});
// out of 5 ONLY 4 are visible (display:block) !!
// 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.
expect(blockDisplayCount).toBe(4);
await TestHelpers.expectVisibleVideoCount(frame, 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",
);
test.slow();
const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom";

View File

@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
// check that the video is on
await expect(
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
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(1);
const pipVideo = videoElements[0];
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
const alice = await addUser("Alice", HOST1);
const bob = await addUser("Bob", HOST1);
const carol = await addUser("Carol", HOST1);
const [alice, bob, carol] = await Promise.all([
addUser("Alice", HOST1),
addUser("Bob", HOST1),
addUser("Carol", HOST1),
]);
const roomName = "Meeting Room";
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
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 }) => {
test.slow(); // Triples the timeout
test.slow();
const { brooks, whistler } = asWidget;
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("incall_leave")
.click();
.click({ timeout: 15000 });
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(whistler.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,
type JSHandle,
type Page,
type FrameLocator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
const PASSWORD = "foobarbaz1!";
export const HOST1 = "https://app.m.localhost/#/welcome";
@@ -26,14 +29,14 @@ export class TestHelpers {
voice: boolean = false,
): Promise<void> {
const buttonName = voice ? "Voice call" : "Video call";
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
await page.getByRole("button", { name: buttonName }).click();
await expect(
page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await page.getByRole("button", { name: buttonName }).click({
timeout: 5000,
});
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> {
@@ -51,15 +54,40 @@ export class TestHelpers {
.click();
}
public static async joinCallInCurrentDM(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, true);
}
public static async joinCallInCurrentRoom(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
// This is the header button that notifies about an ongoing call
const label = audioOnly ? "Voice call started" : "Video call started";
await expect(page.getByText(label)).toBeVisible();
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
await page.getByRole("button", { name: "Join" }).click();
await this.joinCallInRoom(page, audioOnly, false);
}
public static async joinCallInRoom(
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>;
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({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto(host);
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).click();
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
await page.getByRole("link", { name: "Sign in" }).click({
timeout: 10000,
});
await page.getByRole("textbox", { name: "Username" }).fill(username, {
timeout: 10000,
});
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
timeout: 10000,
});
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout as registration can be slow :/
timeout: 15_000,
// Increase timeout here :/ flaky
timeout: 15000,
});
await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await this.maybeDismissBackupChat(page);
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
const mxId = credentials.user_id;
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> {
const toast = page
.locator(".mx_Toast_toast")
@@ -178,10 +235,14 @@ export class TestHelpers {
.getByRole("button", { name: "New conversation" })
.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("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 TestHelpers.maybeDismissKeyBackupToast(page);
@@ -199,6 +260,7 @@ export class TestHelpers {
}
await page.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(page);
}
}
@@ -211,9 +273,12 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
await page.getByRole("option", { name: roomName }).click();
await page.getByRole("button", { name: "Accept" }).click();
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
await expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
@@ -233,8 +298,12 @@ export class TestHelpers {
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
@@ -308,4 +377,52 @@ export class TestHelpers {
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
await expect(
page.getByRole("heading", { name: "Invite new contacts to this" }),
).toBeVisible();
await page.getByRole("button", { name: "Invite" }).click({
timeout: 5000,
});
}
public static async dismissInviteUnknownUserModalDM(
page: Page,
): Promise<void> {
await expect(
page.getByRole("heading", {
name: "Start a chat with this new contact?",
}),
).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click({
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",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
@@ -43,7 +41,7 @@ widgetTest(
).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(
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",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -136,7 +132,7 @@ widgetTest(
).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(
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",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -223,7 +217,7 @@ widgetTest(
).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(
whistler.page.locator('iframe[title="Element Call"]'),

1542
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,7 +12,9 @@ Please see LICENSE in the repository root for full details.
align-items: center;
user-select: none;
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 {

View File

@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
return (
<Drawer.Root
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}
dismissible={onDismiss !== undefined}
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
aria-expanded="true"
aria-haspopup="true"
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-size="lg"
role="button"
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-expanded="true"
aria-haspopup="true"
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-size="lg"
role="button"
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
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-size="lg"
role="button"
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
aria-expanded="true"
aria-haspopup="true"
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-size="lg"
role="button"
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
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-size="lg"
role="button"

View File

@@ -14,7 +14,11 @@ Please see LICENSE in the repository root for full details.
grid-template-areas: ". buttons layout";
align-items: center;
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(
180deg,
rgba(0, 0, 0, 0) 0%,
@@ -118,13 +122,15 @@ Once we exceed 500 we hide everything except the buttons.
@media (max-height: 800px) {
.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) {
.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 {
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,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined,
toggleVideo: undefined,
audioOutputSwitcher: undefined,

View File

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

View File

@@ -97,6 +97,13 @@ export interface ConfigOptions {
enable_video?: boolean;
};
/**
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
* This allows brief sync interruptions without triggering a reconnection message.
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
*/
sync_disconnect_grace_period_ms?: number;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
@@ -155,6 +162,7 @@ export interface ResolvedConfigOptions extends ConfigOptions {
server_name: string;
};
};
sync_disconnect_grace_period_ms: number;
ssla: string;
}
@@ -168,5 +176,6 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
};

View File

@@ -266,6 +266,20 @@ export function Grid<
}, []),
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 [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
@@ -336,10 +350,10 @@ export function Grid<
}
return result;
// The rects may change due to the grid resizing or updating to a new
// generation, but eslint can't statically verify this
// The rects may change due to the grid resizing, changing orientation, or
// updating to a new generation, but eslint can't statically verify this
// 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
const visibleHeight = useMemo(

View File

@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
position: absolute;
inline-size: 404px;
block-size: 233px;
inset-block: 0;
inset-inline: var(--cpd-space-3x);
/* Ensure that spotlight tile lies within the safe area */
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"] {

View File

@@ -18,7 +18,11 @@ Please see LICENSE in the repository root for full details.
position: absolute;
inline-size: 135px;
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) {

View File

@@ -37,10 +37,20 @@ layer(compound);
--cpd-color-border-accent: var(--cpd-color-green-800);
/* 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
96px for typical desktop windows. */
--inline-content-inset: min(
var(--cpd-space-24x),
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
96px for typical desktop windows, and accounts for the safe area. */
--content-inset-left: calc(
env(safe-area-inset-left) +
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);
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;

View File

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

View File

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

View File

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

View File

@@ -227,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
const toggleVideo = useBehavior(muteStates.video.toggle$);
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(
containerRef1,
toggleAudio,
toggleVideo,
setAudioEnabled,

View File

@@ -47,6 +47,7 @@ import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props {
client: MatrixClient;
@@ -91,6 +92,11 @@ export const LobbyView: FC<Props> = ({
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
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(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
@@ -209,7 +215,7 @@ export const LobbyView: FC<Props> = ({
className={classNames(styles.join, {
[styles.wait]: waitingForInvite,
})}
size={waitingForInvite ? "sm" : "lg"}
size={waitingForInvite ? "md" : "lg"}
disabled={waitingForInvite}
onClick={() => {
if (!waitingForInvite) onEnter();

View File

@@ -6,7 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
.preview {
margin-inline: var(--inline-content-inset);
margin-left: var(--content-inset-left);
margin-right: var(--content-inset-right);
min-block-size: 0;
block-size: 50vh;
border-radius: var(--cpd-space-4x);
@@ -80,6 +81,7 @@ video.mirror {
}
.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.
</p>
<button
class="_button_13vu4_8"
class="_button_1nw83_8"
data-kind="secondary"
data-size="lg"
role="button"
@@ -143,7 +143,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
Reconnect
</button>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
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).
</p>
<button
class="_button_13vu4_8"
class="_button_1nw83_8"
data-kind="primary"
data-size="lg"
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).
</p>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
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).
</p>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
role="button"
@@ -754,7 +754,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
You were disconnected from the call.
</p>
<button
class="_button_13vu4_8"
class="_button_1nw83_8"
data-kind="secondary"
data-size="lg"
role="button"
@@ -763,7 +763,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
Reconnect
</button>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
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+.
</p>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
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.
</p>
<button
class="_button_13vu4_8 homeLink"
class="_button_1nw83_8 homeLink"
data-kind="tertiary"
data-size="lg"
role="button"

View File

@@ -147,9 +147,9 @@ exports[`InCallView > rendering > renders 1`] = `
Only works while using app
</p>
<button
class="_button_13vu4_8"
class="_button_1nw83_8"
data-kind="primary"
data-size="sm"
data-size="md"
role="button"
tabindex="0"
>
@@ -305,7 +305,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
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-size="lg"
data-testid="settings-bottom-center"
@@ -329,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false"
aria-disabled="true"
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-size="lg"
data-testid="incall_mute"
@@ -353,7 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false"
aria-disabled="true"
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-size="lg"
data-testid="incall_videomute"
@@ -375,7 +375,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
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-size="lg"
data-testid="incall_leave"

View File

@@ -13,6 +13,7 @@ import { MembershipManagerEvent, Status } from "matrix-js-sdk/lib/matrixrtc";
import { ObservableScope } from "../../ObservableScope";
import { createHomeserverConnected$ } from "./HomeserverConnected";
import { testScope, withTestScheduler } from "../../../utils/test";
/**
* Minimal stub of a Matrix client sufficient for our tests:
@@ -96,19 +97,20 @@ describe("createHomeserverConnected$", () => {
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
// easy enough to read them so I think they can stay.
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
it("is false when sync state is not Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
expect(hsConnected.combined$.value).toBe(false);
});
it("remains false while membership status is not Connected even if sync is Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
});
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Make sync loop OK
client.setSyncState(SyncState.Syncing);
// Indicate probable leave before connection
@@ -118,7 +120,7 @@ describe("createHomeserverConnected$", () => {
});
it("becomes true only when all three conditions are satisfied", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// 1. Sync loop connected
client.setSyncState(SyncState.Syncing);
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
@@ -128,7 +130,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops back to false when sync loop leaves Syncing", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Reach connected state
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
@@ -140,7 +142,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops back to false when membership status becomes disconnected", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -150,7 +152,7 @@ describe("createHomeserverConnected$", () => {
});
it("drops to false when ProbablyLeft is emitted after being true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -160,7 +162,7 @@ describe("createHomeserverConnected$", () => {
});
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
client.setSyncState(SyncState.Syncing);
session.setMembershipStatus(Status.Connected);
expect(hsConnected.combined$.value).toBe(true);
@@ -174,7 +176,7 @@ describe("createHomeserverConnected$", () => {
});
it("composite sequence reflects each individual failure reason", () => {
const hsConnected = createHomeserverConnected$(scope, client, session);
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
// Initially false (sync error + disconnected + not probably left)
expect(hsConnected.combined$.value).toBe(false);
@@ -200,3 +202,62 @@ describe("createHomeserverConnected$", () => {
expect(hsConnected.combined$.value).toBe(false);
});
});
describe("createHomeserverConnected$ - Grace Period", () => {
const GRACE_PERIOD = 5;
function marbleTest(
syncStateMarbles: string,
expectedConnectedMarbles: string,
): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const syncState$ = behavior(syncStateMarbles, {
s: SyncState.Syncing,
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,
});
});
}
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
// - Initial state: Everything is connected
// - Sync error occurs -> should remain connected due to grace period
// - After grace period, not connected
marbleTest("se", "y-----n");
// If the sync error takes longer to occur, it should take equally long for
// the connection state to change
marbleTest("s--e", "y-------n");
});
it("recovers immediately if sync returns during grace period", () => {
// - Initial state: Connected
// - Sync error occurs
// - Sync recovers BEFORE the grace period expires
// - Connection state remains constant
marbleTest("se--s", "y");
});
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
// - Initial error: connection eventually flips to false
// - Back to Syncing -> Must be connected immediately (synchronously)
marbleTest("e-----s", "y----ny");
});
});

View File

@@ -12,9 +12,20 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
import {
fromEvent,
startWith,
map,
tap,
type Observable,
distinctUntilChanged,
switchMap,
of,
delay,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config";
import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { and$ } from "../../../utils/observable";
@@ -35,28 +46,46 @@ export interface HomeserverConnected {
* for the purposes of a MatrixRTC session.
*
* Becomes FALSE if ANY sub-condition is fulfilled:
* 1. Sync loop is not in SyncState.Syncing
* 1. Sync loop is not in SyncState.Syncing (after grace period)
* 2. membershipStatus !== Status.Connected
* 3. probablyLeft === true
*
* @param scope - The observable scope for lifecycle management.
* @param client - The Matrix client to monitor sync state.
* @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
* If not provided, uses the config value (default 10000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected {
// Get grace period from parameter or config (default 10000ms)
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
distinctUntilChanged(),
switchMap((isSyncing) => {
if (isSyncing || graceMs <= 0) {
return of(isSyncing);
}
return of(false).pipe(delay(graceMs), startWith(true));
}),
distinctUntilChanged(),
);
const rtsSession$ = scope.behavior<Status>(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
),
Status.Unknown,
matrixRTCSession.membershipStatus ?? Status.Unknown,
);
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;
}
.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 {
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 { type FC, useRef } from "react";
import { type FC, useState } from "react";
import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
@@ -17,6 +17,7 @@ import {
ReactionSet,
ReactionsRowSize,
} from "./reactions";
import { type Controls } from "./controls";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
@@ -27,6 +28,7 @@ interface TestComponentProps {
onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
initialModalOpen?: boolean;
}
const TestComponent: FC<TestComponentProps> = ({
@@ -34,10 +36,10 @@ const TestComponent: FC<TestComponentProps> = ({
onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
initialModalOpen = false,
}) => {
const ref = useRef<HTMLDivElement | null>(null);
const [modalOpen, setModalOpen] = useState(initialModalOpen);
useCallViewKeyboardShortcuts(
ref,
() => {},
() => {},
setAudioEnabled,
@@ -45,9 +47,25 @@ const TestComponent: FC<TestComponentProps> = ({
toggleHandRaised,
);
return (
<div ref={ref}>
<Button onClick={onButtonClick}>TEST</Button>
</div>
<>
<div id={initialModalOpen ? "root" : undefined}>
<Button onClick={onButtonClick}>TEST</Button>
</div>
{/*// modal lives outside of the root*/}
{modalOpen && (
<dialog
open
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setModalOpen(false);
}
}}
>
<button>InModalButton</button>
</dialog>
)}
</>
);
};
@@ -118,6 +136,27 @@ test("raised hand can be sent via keyboard presses", async () => {
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("raised hand cannot be sent via keyboard presses if modal open and focussed", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
const { getByRole } = render(
<TestComponent
toggleHandRaised={toggleHandRaised}
initialModalOpen={true}
/>,
);
getByRole("button", { name: "InModalButton" }).focus();
await user.keyboard("h");
expect(toggleHandRaised).not.toHaveBeenCalledOnce();
// once we press esc...
await user.keyboard("[Escape]");
// we can toggle the hand raise...
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();
@@ -126,15 +165,48 @@ test("unmuting happens in place of the default action", async () => {
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
>
<TestComponent setAudioEnabled={() => {}} />
</video>,
<div id="root">
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
/>
<TestComponent setAudioEnabled={() => {}} />,
</div>,
);
await user.tab(); // Focus the <video>
await user.keyboard("[Space]");
expect(defaultPrevented).toBeCalledWith(true);
});
test("escape button triggers the controls back action", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(<TestComponent setAudioEnabled={() => {}} />);
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});
test("escape button does not trigger back if sth else is focused", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
const { getByRole } = render(<TestComponent initialModalOpen={true} />);
getByRole("button", { name: "InModalButton" }).focus();
// First Escape: the dialog's onKeyDown intercepts it and closes the modal.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).not.toHaveBeenCalled();
// Second Escape: modal is gone, focus has fallen back to document.body,
// which *does* contain the ref div, so the hook fires and back IS triggered.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});

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.
*/
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 {
@@ -18,22 +19,61 @@ import {
* 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).
*/
const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
const focusedElement = document.activeElement;
return focusedElement !== null && focusedElement.contains(e);
const mayReceiveKeyEvents = (): boolean => {
const root = document.getElementById("root");
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(
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(
focusElement: RefObject<HTMLElement | null>,
toggleAudio: (() => void) | null,
toggleVideo: (() => void) | null,
setAudioEnabled: ((enabled: boolean) => void) | null,
sendReaction: (reaction: ReactionOption) => void,
toggleHandRaised: () => void,
sendReaction: ((reaction: ReactionOption) => void) | null,
toggleHandRaised: (() => void) | null,
): void {
const spacebarHeld = useRef(false);
@@ -45,8 +85,8 @@ export function useCallViewKeyboardShortcuts(
"keydown",
useCallback(
(event: KeyboardEvent) => {
if (focusElement.current === null) return;
if (!mayReceiveKeyEvents(focusElement.current)) return;
logger.info("Keydown event", event);
if (!mayReceiveKeyEvents()) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
@@ -56,7 +96,7 @@ export function useCallViewKeyboardShortcuts(
} else if (event.key === "v") {
event.preventDefault();
toggleVideo?.();
} else if (event.key === " ") {
} else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
event.preventDefault();
if (!spacebarHeld.current) {
spacebarHeld.current = true;
@@ -64,14 +104,16 @@ export function useCallViewKeyboardShortcuts(
}
} else if (event.key === "h") {
event.preventDefault();
toggleHandRaised();
toggleHandRaised?.();
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
sendReaction?.(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") {
logger.info("Escape key pressed, triggering onBackButtonPressed");
window.controls.onBackButtonPressed?.();
}
},
[
focusElement,
toggleVideo,
toggleAudio,
setAudioEnabled,
@@ -90,15 +132,13 @@ export function useCallViewKeyboardShortcuts(
"keyup",
useCallback(
(event: KeyboardEvent) => {
if (focusElement.current === null) return;
if (!mayReceiveKeyEvents(focusElement.current)) return;
if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
if (event.key === " ") {
spacebarHeld.current = false;
setAudioEnabled?.(false);
}
},
[focusElement, setAudioEnabled],
[setAudioEnabled],
),
);