mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
Merge branch 'livekit' into fkwp/feature_add_delegation_info_to_legacy_jwt_request
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
// ========
|
// ========
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
142
playwright/utils/synapse-admin.ts
Normal file
142
playwright/utils/synapse-admin.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
1446
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user