mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
Merge branch 'livekit' into valere/devx/livekit_logs
This commit is contained in:
28
.github/workflows/build.yaml
vendored
28
.github/workflows/build.yaml
vendored
@@ -85,3 +85,31 @@ jobs:
|
|||||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
build_storybook:
|
||||||
|
name: Build Storybook
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
- name: pnpm cache
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
cache: "pnpm"
|
||||||
|
node-version-file: ".node-version"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
|
||||||
|
- name: Build Storybook
|
||||||
|
run: pnpm run build-storybook
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: build-output-storybook
|
||||||
|
path: storybook-static
|
||||||
|
# We'll only use this in a triggered job, then we're done with it
|
||||||
|
retention-days: 1
|
||||||
|
|||||||
10
.github/workflows/deploy-to-netlify.yaml
vendored
10
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -17,7 +17,7 @@ on:
|
|||||||
package:
|
package:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
|
||||||
artifact_run_id:
|
artifact_run_id:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
step: start
|
step: start
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
env: Netlify
|
env: ${{ inputs.package}}
|
||||||
ref: ${{ inputs.deployment_ref }}
|
ref: ${{ inputs.deployment_ref }}
|
||||||
desc: |
|
desc: |
|
||||||
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
|
||||||
@@ -59,9 +59,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Add redirects file
|
- name: Add redirects file
|
||||||
# We fetch from github directly as we don't bother checking out the repo
|
# We fetch from github directly as we don't bother checking out the repo
|
||||||
|
# Not needed for storybook deployments
|
||||||
|
if: inputs.package != 'storybook'
|
||||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||||
|
|
||||||
- name: Add config file
|
- name: Add config file
|
||||||
|
# Not needed for storybook deployments
|
||||||
|
if: inputs.package != 'storybook'
|
||||||
run: |
|
run: |
|
||||||
if [ "${INPUTS_PACKAGE}" = "full" ]; then
|
if [ "${INPUTS_PACKAGE}" = "full" ]; then
|
||||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
|
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
|
||||||
@@ -78,7 +82,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
publish-dir: webapp
|
publish-dir: webapp
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || inputs.package == 'storybook' && format('pr{0}-storybook', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||||
env:
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|||||||
20
.github/workflows/pr-deploy.yaml
vendored
20
.github/workflows/pr-deploy.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
# 2. Event must be a pull_request
|
# 2. Event must be a pull_request
|
||||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||||
if: >
|
if: >
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.event == 'pull_request' &&
|
github.event.workflow_run.event == 'pull_request' &&
|
||||||
github.event.workflow_run.head_repository.full_name == github.repository
|
github.event.workflow_run.head_repository.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -63,6 +63,24 @@ jobs:
|
|||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
|
netlify-storybook:
|
||||||
|
needs: prdetails
|
||||||
|
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'storybook build') }}
|
||||||
|
permissions:
|
||||||
|
deployments: write
|
||||||
|
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||||
|
with:
|
||||||
|
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||||
|
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||||
|
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||||
|
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||||
|
package: storybook
|
||||||
|
secrets:
|
||||||
|
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
|
||||||
needs: prdetails
|
needs: prdetails
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Synapse reverse proxy including .well-known/matrix/client
|
# Synapse reverse proxy including .well-known/matrix/client
|
||||||
@@ -91,8 +89,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# MatrixRTC reverse proxy
|
# MatrixRTC reverse proxy
|
||||||
@@ -144,8 +140,6 @@ server {
|
|||||||
proxy_pass http://livekit-sfu:7880/;
|
proxy_pass http://livekit-sfu:7880/;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# MatrixRTC reverse proxy
|
# MatrixRTC reverse proxy
|
||||||
@@ -192,8 +186,6 @@ server {
|
|||||||
proxy_pass http://livekit-sfu-1:17880/;
|
proxy_pass http://livekit-sfu-1:17880/;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convenience reverse proxy for the call.m.localhost domain to element call
|
# Convenience reverse proxy for the call.m.localhost domain to element call
|
||||||
@@ -243,7 +235,6 @@ server {
|
|||||||
proxy_pass http://host.docker.internal:8080;
|
proxy_pass http://host.docker.internal:8080;
|
||||||
|
|
||||||
}
|
}
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +267,6 @@ server {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convenience reverse proxy app.othersite.m.localhost for element web
|
# Convenience reverse proxy app.othersite.m.localhost for element web
|
||||||
@@ -309,6 +298,4 @@ server {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
},
|
},
|
||||||
"layout_grid_label": "Grid",
|
"layout_grid_label": "Grid",
|
||||||
"layout_spotlight_label": "Spotlight",
|
"layout_spotlight_label": "Spotlight",
|
||||||
|
"layout_switch_label": "Layout",
|
||||||
"lobby": {
|
"lobby": {
|
||||||
"ask_to_join": "Request to join call",
|
"ask_to_join": "Request to join call",
|
||||||
"join_as_guest": "Join as guest",
|
"join_as_guest": "Join as guest",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -54,12 +54,34 @@ 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";
|
}
|
||||||
|
|
||||||
|
public static async joinCallInRoom(
|
||||||
|
page: Page,
|
||||||
|
audioOnly: boolean = false,
|
||||||
|
isDM: boolean = false,
|
||||||
|
): Promise<void> {
|
||||||
|
// XXX This using the notification toast to join the room.
|
||||||
|
// Not the button in the header
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
const label = isDM
|
||||||
|
? audioOnly
|
||||||
|
? "Incoming voice call"
|
||||||
|
: "Incoming video call"
|
||||||
|
: "Group call started";
|
||||||
await expect(page.getByText(label)).toBeVisible({
|
await expect(page.getByText(label)).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
@@ -120,8 +142,7 @@ export class TestHelpers {
|
|||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.maybeDismissBrowserNotSupportedToast(page);
|
await this.dismissStartupToasts(page);
|
||||||
await this.maybeDismissServiceWorkerWarningToast(page);
|
|
||||||
|
|
||||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||||
|
|
||||||
@@ -132,57 +153,39 @@ export class TestHelpers {
|
|||||||
return { page, clientHandle, mxId };
|
return { page, clientHandle, mxId };
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async maybeDismissBrowserNotSupportedToast(
|
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
|
||||||
page: Page,
|
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
|
||||||
): Promise<void> {
|
// dismiss (if part of expected toats)
|
||||||
const browserUnsupportedToast = page
|
public static async dismissStartupToasts(page: Page): Promise<void> {
|
||||||
.getByText("Element does not support this browser")
|
const expectedToasts = [
|
||||||
.locator("..")
|
{ title: "Failed to load service worker", button: "OK" },
|
||||||
.locator("..");
|
{ title: "Back up your chats", button: "Dismiss" },
|
||||||
|
{ title: "Element does not support this browser", button: "Dismiss" },
|
||||||
|
];
|
||||||
|
|
||||||
// Dismiss incompatible browser toast
|
const toast = page.locator(".mx_Toast_toast");
|
||||||
const dismissButton = browserUnsupportedToast.getByRole("button", {
|
|
||||||
name: "Dismiss",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await expect(dismissButton).toBeVisible({ timeout: 700 });
|
|
||||||
await dismissButton.click();
|
|
||||||
} catch {
|
|
||||||
// dismissButton not visible, continue as normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async maybeDismissServiceWorkerWarningToast(
|
// eslint-disable-next-line no-constant-condition
|
||||||
page: Page,
|
while (true) {
|
||||||
): Promise<void> {
|
try {
|
||||||
const toast = page
|
await toast.waitFor({ state: "visible", timeout: 700 });
|
||||||
.locator(".mx_Toast_toast")
|
const title = await toast.locator(".mx_Toast_title h2").textContent();
|
||||||
.getByText("Failed to load service worker");
|
|
||||||
|
|
||||||
try {
|
// Find the matching toast config
|
||||||
await expect(toast).toBeVisible({ timeout: 700 });
|
const toastConfig = expectedToasts.find((t) =>
|
||||||
await page
|
title?.includes(t.title),
|
||||||
.locator(".mx_Toast_toast")
|
);
|
||||||
.getByRole("button", { name: "OK" })
|
|
||||||
.click();
|
|
||||||
} catch {
|
|
||||||
// toast not visible, continue as normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
|
if (toastConfig) {
|
||||||
const toast = page
|
await toast.getByRole("button", { name: toastConfig.button }).click();
|
||||||
.locator(".mx_Toast_toast")
|
} else {
|
||||||
.getByText("Back up your chats");
|
// Unknown toast. We don't want to act on unknown toasts
|
||||||
|
break;
|
||||||
try {
|
}
|
||||||
await expect(toast).toBeVisible({ timeout: 700 });
|
} catch {
|
||||||
await page
|
// No toast visible, exit loop
|
||||||
.locator(".mx_Toast_toast")
|
break;
|
||||||
.getByRole("button", { name: "Dismiss" })
|
}
|
||||||
.click();
|
|
||||||
} catch {
|
|
||||||
// toast not visible, continue as normal
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ export class TestHelpers {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
await expect(page.getByText("Encryption enabled")).toBeVisible();
|
||||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
await TestHelpers.dismissStartupToasts(page);
|
||||||
|
|
||||||
// Invite users if any
|
// Invite users if any
|
||||||
if (andInvite.length > 0) {
|
if (andInvite.length > 0) {
|
||||||
@@ -244,7 +247,7 @@ export class TestHelpers {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("main").getByRole("heading", { name: roomName }),
|
page.getByRole("main").getByRole("heading", { name: roomName }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await TestHelpers.maybeDismissKeyBackupToast(page);
|
await TestHelpers.dismissStartupToasts(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,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"]'),
|
||||||
@@ -132,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"]'),
|
||||||
|
|||||||
1450
pnpm-lock.yaml
generated
1450
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,24 @@ export const Default: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithAudioAndVideoOptions: Story = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
audioEnabled: false,
|
||||||
|
videoEnabled: true,
|
||||||
|
audioOptions: [
|
||||||
|
{ label: "Microphone 1", id: "1" },
|
||||||
|
{ label: "Microphone 2", id: "2" },
|
||||||
|
],
|
||||||
|
videoOptions: [
|
||||||
|
{ label: "Camera 1", id: "1" },
|
||||||
|
{ label: "Camera 2", id: "2" },
|
||||||
|
],
|
||||||
|
selectedAudio: "2",
|
||||||
|
selectedVideo: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
export const WithLogo: Story = {
|
export const WithLogo: Story = {
|
||||||
...Default,
|
...Default,
|
||||||
args: {
|
args: {
|
||||||
@@ -157,6 +175,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,
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type FC, type JSX, type Ref, useMemo } from "react";
|
import { type FC, type JSX, type Ref, useMemo } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { Switch } from "@vector-im/compound-web";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import {
|
||||||
|
SpotlightIcon,
|
||||||
|
GridIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import LogoMark from "../icons/LogoMark.svg?react";
|
import LogoMark from "../icons/LogoMark.svg?react";
|
||||||
import LogoType from "../icons/LogoType.svg?react";
|
import LogoType from "../icons/LogoType.svg?react";
|
||||||
@@ -23,8 +29,11 @@ import {
|
|||||||
type ReactionData,
|
type ReactionData,
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import styles from "./CallFooter.module.css";
|
import styles from "./CallFooter.module.css";
|
||||||
import { LayoutToggle } from "../room/LayoutToggle";
|
|
||||||
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
import { type GridMode } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import {
|
||||||
|
MediaMuteAndSwitchButton,
|
||||||
|
type MenuOptions,
|
||||||
|
} from "./MediaMuteAndSwitchButton";
|
||||||
|
|
||||||
export interface AudioOutputSwitcher {
|
export interface AudioOutputSwitcher {
|
||||||
targetOutput: string;
|
targetOutput: string;
|
||||||
@@ -74,6 +83,13 @@ export interface FooterProps {
|
|||||||
// debug stuff
|
// debug stuff
|
||||||
debugTileLayout?: boolean;
|
debugTileLayout?: boolean;
|
||||||
tileStoreGeneration?: number;
|
tileStoreGeneration?: number;
|
||||||
|
|
||||||
|
audioOptions?: MenuOptions[];
|
||||||
|
videoOptions?: MenuOptions[];
|
||||||
|
selectedAudio?: string;
|
||||||
|
selectedVideo?: string;
|
||||||
|
selectAudioDevice?: (deviceId: string) => void;
|
||||||
|
selectVideoDevice?: (deviceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CallFooter: FC<FooterProps> = ({
|
export const CallFooter: FC<FooterProps> = ({
|
||||||
@@ -99,9 +115,16 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
hangup,
|
hangup,
|
||||||
debugTileLayout,
|
debugTileLayout,
|
||||||
tileStoreGeneration,
|
tileStoreGeneration,
|
||||||
|
|
||||||
|
audioOptions,
|
||||||
|
videoOptions,
|
||||||
|
selectedAudio,
|
||||||
|
selectedVideo,
|
||||||
|
selectAudioDevice,
|
||||||
|
selectVideoDevice,
|
||||||
}) => {
|
}) => {
|
||||||
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;
|
||||||
@@ -120,24 +143,58 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push(
|
if ((audioOptions?.length ?? 0) > 0) {
|
||||||
<MicButton
|
buttons.push(
|
||||||
size={buttonSize}
|
<MediaMuteAndSwitchButton
|
||||||
key="audio"
|
title={"Mic Source"}
|
||||||
enabled={audioEnabled ?? false}
|
key="audio"
|
||||||
onClick={toggleAudio}
|
iconsAndLabels="audio"
|
||||||
disabled={toggleAudio === undefined}
|
enabled={audioEnabled ?? false}
|
||||||
data-testid="incall_mute"
|
onMuteClick={toggleAudio}
|
||||||
/>,
|
data-testid="incall_mute"
|
||||||
<VideoButton
|
options={audioOptions}
|
||||||
size={buttonSize}
|
selectedOption={selectedAudio}
|
||||||
key="video"
|
onSelect={selectAudioDevice}
|
||||||
enabled={videoEnabled ?? false}
|
/>,
|
||||||
onClick={toggleVideo}
|
);
|
||||||
disabled={toggleVideo === undefined}
|
} else {
|
||||||
data-testid="incall_videomute"
|
buttons.push(
|
||||||
/>,
|
<MicButton
|
||||||
);
|
size={buttonSize}
|
||||||
|
key="audio"
|
||||||
|
enabled={audioEnabled ?? false}
|
||||||
|
onClick={toggleAudio}
|
||||||
|
disabled={toggleAudio === undefined}
|
||||||
|
data-testid="incall_mute"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ((videoOptions?.length ?? 0) > 0) {
|
||||||
|
buttons.push(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title={"Camera Source"}
|
||||||
|
key="video"
|
||||||
|
iconsAndLabels="video"
|
||||||
|
enabled={videoEnabled ?? false}
|
||||||
|
onMuteClick={toggleVideo}
|
||||||
|
data-testid="incall_videomute"
|
||||||
|
options={videoOptions}
|
||||||
|
selectedOption={selectedVideo}
|
||||||
|
onSelect={selectVideoDevice}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buttons.push(
|
||||||
|
<VideoButton
|
||||||
|
size={buttonSize}
|
||||||
|
key="video"
|
||||||
|
enabled={videoEnabled ?? false}
|
||||||
|
onClick={toggleVideo}
|
||||||
|
disabled={toggleVideo === undefined}
|
||||||
|
data-testid="incall_videomute"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (toggleScreenSharing !== undefined) {
|
if (toggleScreenSharing !== undefined) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
@@ -232,10 +289,18 @@ export const CallFooter: FC<FooterProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
|
||||||
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
{setLayoutMode && layoutMode && showLayoutSwitcher && (
|
||||||
<LayoutToggle
|
<Switch<"spotlight", "grid">
|
||||||
|
name="layoutMode"
|
||||||
|
aria-label={t("layout_switch_label")}
|
||||||
|
leftLabel={t("layout_spotlight_label")}
|
||||||
|
leftValue="spotlight"
|
||||||
|
leftIcon={SpotlightIcon}
|
||||||
|
rightLabel={t("layout_grid_label")}
|
||||||
|
rightValue="grid"
|
||||||
|
rightIcon={GridIcon}
|
||||||
className={styles.layout}
|
className={styles.layout}
|
||||||
layout={layoutMode}
|
value={layoutMode}
|
||||||
setLayout={setLayoutMode}
|
onChange={setLayoutMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
37
src/components/MediaMuteAndSwitchButton.module.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||||
|
border-radius: 32px;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.containerOpen {
|
||||||
|
background-color: var(--cpd-color-bg-action-primary-pressed);
|
||||||
|
}
|
||||||
|
.chevronIconOpen > svg {
|
||||||
|
color: var(--cpd-color-icon-on-solid-primary);
|
||||||
|
}
|
||||||
|
.menuButton {
|
||||||
|
width: 40px;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
.itemIcon {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate {
|
||||||
|
animation: spinner 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
117
src/components/MediaMuteAndSwitchButton.stories.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { fn, userEvent, within, expect } from "storybook/test";
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
component: MediaMuteAndSwitchButton,
|
||||||
|
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
title: "SomeMenu",
|
||||||
|
iconsAndLabels: {
|
||||||
|
IconEnabled: AdvancedSettingsIcon,
|
||||||
|
IconDisabled: AdvancedSettingsIcon,
|
||||||
|
enabledLabel: "Enabled",
|
||||||
|
disabledLabel: "Disabled",
|
||||||
|
optionsButtonLabel: "Options",
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: "option 1", id: "1" },
|
||||||
|
{ label: "option 2", id: "2" },
|
||||||
|
],
|
||||||
|
selectedOption: "1",
|
||||||
|
onMuteClick: fn(),
|
||||||
|
onSelect: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AudioMute: Story = {
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: false,
|
||||||
|
options: [
|
||||||
|
{ label: "Microphone 1", id: "1" },
|
||||||
|
{ label: "Microphone 2", id: "2" },
|
||||||
|
],
|
||||||
|
toggles: [
|
||||||
|
{
|
||||||
|
label: "example toggle",
|
||||||
|
id: "t0",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedOption: "2",
|
||||||
|
},
|
||||||
|
play: async ({ args, canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
|
||||||
|
// (both are TODO placeholders in the component). The mute button is first in the DOM.
|
||||||
|
const muteButton = canvas.getByLabelText("Unmute microphone");
|
||||||
|
await userEvent.click(muteButton);
|
||||||
|
await expect(args.onMuteClick).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AudioUnmute: Story = {
|
||||||
|
args: {
|
||||||
|
title: "Microphone",
|
||||||
|
iconsAndLabels: "audio",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Microphone 1", id: "1" },
|
||||||
|
{ label: "Microphone 2", id: "2" },
|
||||||
|
],
|
||||||
|
toggles: [],
|
||||||
|
selectedOption: "2",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoMute: Story = {
|
||||||
|
args: {
|
||||||
|
title: "Camera",
|
||||||
|
iconsAndLabels: "video",
|
||||||
|
enabled: false,
|
||||||
|
options: [
|
||||||
|
{ label: "Camera 1", id: "1" },
|
||||||
|
{ label: "Camera 2", id: "2" },
|
||||||
|
],
|
||||||
|
toggles: [],
|
||||||
|
selectedOption: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoUnmute: Story = {
|
||||||
|
args: {
|
||||||
|
title: "Camera",
|
||||||
|
iconsAndLabels: "video",
|
||||||
|
enabled: true,
|
||||||
|
options: [
|
||||||
|
{ label: "Camera 1", id: "1" },
|
||||||
|
{ label: "Camera 2", id: "2" },
|
||||||
|
],
|
||||||
|
toggles: [
|
||||||
|
{
|
||||||
|
label: "Blur Background",
|
||||||
|
id: "background_blurring",
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedOption: "2",
|
||||||
|
},
|
||||||
|
};
|
||||||
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
226
src/components/MediaMuteAndSwitchButton.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2023, 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { act, render, screen, type RenderResult } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { type JSX, useState } from "react";
|
||||||
|
|
||||||
|
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
|
||||||
|
|
||||||
|
describe("MediaMuteAndSwitchButton", () => {
|
||||||
|
test("renders", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MediaMuteAndSwitchButton title={"Switcher"} />,
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correct audio and video labels", () => {
|
||||||
|
const renderLabels = (
|
||||||
|
type: "video" | "audio",
|
||||||
|
enabled: boolean,
|
||||||
|
): RenderResult => {
|
||||||
|
return render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title={"Switcher"}
|
||||||
|
iconsAndLabels={type}
|
||||||
|
enabled={enabled}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const renderAudioEndabled = renderLabels("audio", true);
|
||||||
|
const renderAudioDisabled = renderLabels("audio", false);
|
||||||
|
const renderVideoEnabled = renderLabels("video", true);
|
||||||
|
const renderVideoDisabled = renderLabels("video", false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
renderVideoEnabled.getByRole("button", { name: "Start video" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls mute on mute press", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onMute = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title={"Switcher"}
|
||||||
|
onMuteClick={onMute}
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Mute microphone" }));
|
||||||
|
|
||||||
|
expect(onMute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls select callback on menu click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
options={[
|
||||||
|
{ label: "Microphone 1", id: "mic1" },
|
||||||
|
{ label: "Microphone 2", id: "mic2" },
|
||||||
|
]}
|
||||||
|
selectedOption="mic1"
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("mic2");
|
||||||
|
});
|
||||||
|
test("does not call select callback on already selected menu click", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
options={[
|
||||||
|
{ label: "Microphone 1", id: "mic1" },
|
||||||
|
{ label: "Microphone 2", id: "mic2" },
|
||||||
|
]}
|
||||||
|
selectedOption="mic1"
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
|
||||||
|
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders menu spinner until selection updates for the component", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
const onSelectPressed = vi.fn();
|
||||||
|
const onOptionUpdated = vi.fn();
|
||||||
|
function Wrapper(): JSX.Element {
|
||||||
|
const [selectedOption, setSelectedOption] = useState("mic1");
|
||||||
|
return (
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
options={[
|
||||||
|
{ label: "Microphone 1", id: "mic1" },
|
||||||
|
{ label: "Microphone 2", id: "mic2" },
|
||||||
|
]}
|
||||||
|
selectedOption={selectedOption}
|
||||||
|
onSelect={(id) => {
|
||||||
|
onSelectPressed();
|
||||||
|
void promise.then(() => {
|
||||||
|
setSelectedOption(id);
|
||||||
|
onOptionUpdated();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByRole } = render(<Wrapper />);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
|
||||||
|
|
||||||
|
expect(onSelectPressed).toHaveBeenCalled();
|
||||||
|
expect(onOptionUpdated).not.toHaveBeenCalled();
|
||||||
|
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
|
||||||
|
// so a spinner should appear on the mic2 item
|
||||||
|
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||||
|
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
|
||||||
|
|
||||||
|
// The currently-selected mic1 item should not have a spinner
|
||||||
|
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||||
|
expect(mic1Item.querySelector(".rotate")).toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
// resolve the promise that acutally updates the select option.
|
||||||
|
resolve();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onOptionUpdated).toHaveBeenCalled();
|
||||||
|
// Spinner should now be gone since the selection has caught up
|
||||||
|
const mic2ItemAfter = screen.getByRole("menuitem", {
|
||||||
|
name: "Microphone 2",
|
||||||
|
});
|
||||||
|
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders menu with toggle control and calls toggle callback", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
const toggle = screen.getByRole("menuitemcheckbox", {
|
||||||
|
name: "Background blur",
|
||||||
|
});
|
||||||
|
expect(toggle).toBeInTheDocument();
|
||||||
|
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||||
|
|
||||||
|
await user.click(toggle);
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("bg_blur");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders check icon to mark the selected menu item", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { getByRole } = render(
|
||||||
|
<MediaMuteAndSwitchButton
|
||||||
|
title="Switcher"
|
||||||
|
iconsAndLabels="audio"
|
||||||
|
enabled={true}
|
||||||
|
options={[
|
||||||
|
{ label: "Microphone 1", id: "mic1" },
|
||||||
|
{ label: "Microphone 2", id: "mic2" },
|
||||||
|
]}
|
||||||
|
selectedOption="mic2"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// open menu
|
||||||
|
await user.click(getByRole("button", { name: "Microphone" }));
|
||||||
|
|
||||||
|
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
|
||||||
|
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
|
||||||
|
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
|
||||||
|
|
||||||
|
// The unselected item (mic1) only renders its IconOptions SVG
|
||||||
|
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
|
||||||
|
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
230
src/components/MediaMuteAndSwitchButton.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/*
|
||||||
|
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 { type ComponentType, useState, type FC } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
ToggleMenuItem,
|
||||||
|
} from "@vector-im/compound-web";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
MicOffSolidIcon,
|
||||||
|
MicOnIcon,
|
||||||
|
MicOnSolidIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
VideoCallIcon,
|
||||||
|
VideoCallOffSolidIcon,
|
||||||
|
VideoCallSolidIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import styles from "./MediaMuteAndSwitchButton.module.css";
|
||||||
|
|
||||||
|
export interface MenuOptions {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
export interface ToggleOption {
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconsAndLabels {
|
||||||
|
/** The Icon used if the mute button is enabled */
|
||||||
|
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||||
|
/** The Icon used if the mute button is disabled */
|
||||||
|
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||||
|
/** The icon used for the different options */
|
||||||
|
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||||
|
enabledLabel: string;
|
||||||
|
disabledLabel: string;
|
||||||
|
optionsButtonLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMuteAndSwitchButtonProps {
|
||||||
|
/** The title used in the Switcher modal. */
|
||||||
|
title: string;
|
||||||
|
/** If the Mute button is enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Callback if the mute button is clicked */
|
||||||
|
onMuteClick?: () => void;
|
||||||
|
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
|
||||||
|
/** The options available for the media device selector modal */
|
||||||
|
options?: MenuOptions[];
|
||||||
|
/** The option that will currently be rendered as the selected option */
|
||||||
|
selectedOption?: string;
|
||||||
|
/**
|
||||||
|
* The available toggles (including there current state)
|
||||||
|
* The toggle state is not stored by this component.
|
||||||
|
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
|
||||||
|
*/
|
||||||
|
toggles?: ToggleOption[];
|
||||||
|
/**
|
||||||
|
* For any toggle and option this method will be called.
|
||||||
|
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
|
||||||
|
*/
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
onMuteClick,
|
||||||
|
iconsAndLabels: iconsAndLabelsWithDefaultCases,
|
||||||
|
options,
|
||||||
|
selectedOption,
|
||||||
|
toggles,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
let iconsAndLabels: IconsAndLabels | undefined;
|
||||||
|
switch (iconsAndLabelsWithDefaultCases) {
|
||||||
|
case "video":
|
||||||
|
iconsAndLabels = {
|
||||||
|
IconEnabled: VideoCallSolidIcon,
|
||||||
|
IconDisabled: VideoCallOffSolidIcon,
|
||||||
|
IconOptions: VideoCallIcon,
|
||||||
|
disabledLabel: t("stop_video_button_label"),
|
||||||
|
enabledLabel: t("start_video_button_label"),
|
||||||
|
optionsButtonLabel: t("settings.devices.microphone"),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "audio":
|
||||||
|
iconsAndLabels = {
|
||||||
|
IconEnabled: MicOnSolidIcon,
|
||||||
|
IconDisabled: MicOffSolidIcon,
|
||||||
|
IconOptions: MicOnIcon,
|
||||||
|
disabledLabel: t("mute_microphone_button_label"),
|
||||||
|
enabledLabel: t("unmute_microphone_button_label"),
|
||||||
|
optionsButtonLabel: t("settings.devices.microphone"),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
iconsAndLabels = iconsAndLabelsWithDefaultCases;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
IconEnabled,
|
||||||
|
IconDisabled,
|
||||||
|
IconOptions,
|
||||||
|
disabledLabel,
|
||||||
|
enabledLabel,
|
||||||
|
optionsButtonLabel,
|
||||||
|
} = iconsAndLabels ?? {
|
||||||
|
IconEnabled: undefined,
|
||||||
|
IconDisabled: undefined,
|
||||||
|
IconOptions: undefined,
|
||||||
|
disabledLabel: undefined,
|
||||||
|
enabledLabel: undefined,
|
||||||
|
optionsButtonLabel: undefined,
|
||||||
|
};
|
||||||
|
{
|
||||||
|
logger.info(
|
||||||
|
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
|
||||||
|
selectedOption,
|
||||||
|
" !==",
|
||||||
|
"option.id",
|
||||||
|
" && ",
|
||||||
|
plannedSelection,
|
||||||
|
" === ",
|
||||||
|
"option.id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
[styles.container]: true,
|
||||||
|
[styles.containerOpen]: menuOpen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* The mute button lives inside */}
|
||||||
|
<Button
|
||||||
|
iconOnly
|
||||||
|
Icon={enabled ? IconEnabled : IconDisabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
onMuteClick?.();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
kind={enabled ? "secondary" : "primary"}
|
||||||
|
size="lg"
|
||||||
|
className={styles.button}
|
||||||
|
aria-label={enabled ? disabledLabel : enabledLabel}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
title={title}
|
||||||
|
showTitle={true}
|
||||||
|
open={menuOpen}
|
||||||
|
onOpenChange={setMenuOpen}
|
||||||
|
side="top"
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
iconOnly
|
||||||
|
className={classNames({
|
||||||
|
[styles.menuButton]: true,
|
||||||
|
[styles.chevronIconOpen]: menuOpen,
|
||||||
|
})}
|
||||||
|
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
|
||||||
|
kind={"tertiary"}
|
||||||
|
size="lg"
|
||||||
|
aria-label={optionsButtonLabel}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{options?.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
hideChevron
|
||||||
|
label={option.label}
|
||||||
|
Icon={
|
||||||
|
IconOptions && (
|
||||||
|
<IconOptions
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className={styles.itemIcon}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (option.id === selectedOption) return;
|
||||||
|
setPlannedSelection(option.id);
|
||||||
|
onSelect?.(option.id);
|
||||||
|
}}
|
||||||
|
key={option.id}
|
||||||
|
>
|
||||||
|
{selectedOption === option.id && (
|
||||||
|
<CheckIcon width={24} height={24} />
|
||||||
|
)}
|
||||||
|
{selectedOption !== option.id && plannedSelection === option.id && (
|
||||||
|
<SpinnerIcon width={24} height={24} className={styles.rotate} />
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
{(toggles?.length ?? 0) > 0 && <hr />}
|
||||||
|
{toggles?.map((toggle) => (
|
||||||
|
<ToggleMenuItem
|
||||||
|
label={toggle.label}
|
||||||
|
onSelect={(e) => {
|
||||||
|
onSelect?.(toggle.id);
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
checked={toggle.enabled}
|
||||||
|
key={toggle.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`MediaMuteAndSwitchButton > renders 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="container"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="_button_1nw83_8 button _icon-only_1nw83_53"
|
||||||
|
data-kind="primary"
|
||||||
|
data-size="lg"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
|
||||||
|
data-kind="tertiary"
|
||||||
|
data-size="lg"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_0_"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -97,6 +97,13 @@ export interface ConfigOptions {
|
|||||||
enable_video?: boolean;
|
enable_video?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
|
||||||
|
* This allows brief sync interruptions without triggering a reconnection message.
|
||||||
|
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
|
||||||
|
*/
|
||||||
|
sync_disconnect_grace_period_ms?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are low level options that are used to configure the MatrixRTC session.
|
* These are low level options that are used to configure the MatrixRTC session.
|
||||||
* Take care when changing these options.
|
* Take care when changing these options.
|
||||||
@@ -155,7 +162,16 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
|||||||
server_name: string;
|
server_name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
sync_disconnect_grace_period_ms: number;
|
||||||
ssla: string;
|
ssla: string;
|
||||||
|
matrix_rtc_session: {
|
||||||
|
wait_for_key_rotation_ms?: number;
|
||||||
|
delayed_leave_event_delay_ms: number;
|
||||||
|
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||||
|
delayed_leave_event_restart_ms?: number;
|
||||||
|
network_error_retry_ms: number;
|
||||||
|
membership_event_expiry_ms?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||||
@@ -168,5 +184,10 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
|||||||
features: {
|
features: {
|
||||||
feature_use_device_session_member_events: true,
|
feature_use_device_session_member_events: true,
|
||||||
},
|
},
|
||||||
|
sync_disconnect_grace_period_ms: 10000,
|
||||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||||
|
matrix_rtc_session: {
|
||||||
|
delayed_leave_event_delay_ms: 10000,
|
||||||
|
network_error_retry_ms: 1000,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,6 +84,99 @@ describe("getSFUConfigWithOpenID", () => {
|
|||||||
expect.fail("Expected test to throw;");
|
expect.fail("Expected test to throw;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
|
||||||
|
fetchMock.post(
|
||||||
|
"https://sfu.example.org/sfu/get",
|
||||||
|
(url, opts) => {
|
||||||
|
callCount++;
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
|
||||||
|
// First call: check if it has delay parts and return 400
|
||||||
|
if (callCount === 1) {
|
||||||
|
expect(body).toHaveProperty("delay_id", "mock_delay_id");
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: check if delay parts were stripped and return success
|
||||||
|
expect(body).not.toHaveProperty("delay_id");
|
||||||
|
expect(body).not.toHaveProperty("delay_timeout");
|
||||||
|
expect(body).not.toHaveProperty("delay_cs_api_url");
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: sfuUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT
|
||||||
|
const config = await getSFUConfigWithOpenID(
|
||||||
|
matrixClient,
|
||||||
|
ownMemberMock,
|
||||||
|
"https://sfu.example.org",
|
||||||
|
"!example_room_id",
|
||||||
|
{
|
||||||
|
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
|
||||||
|
delayId: "mock_delay_id",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.jwt).toBe(testJWTToken);
|
||||||
|
expect(callCount).toBe(2);
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
|
||||||
|
fetchMock.post(
|
||||||
|
"https://sfu.example.org/sfu/get",
|
||||||
|
(url, opts) => {
|
||||||
|
const body = JSON.parse(opts.body as string);
|
||||||
|
|
||||||
|
// Verify, that the request contains the expected delay parameters
|
||||||
|
if (
|
||||||
|
body.delay_id === "mock_delay_id" &&
|
||||||
|
body.delay_timeout === 10000 &&
|
||||||
|
body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: sfuUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: { error: "Missing expected delay params" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ overwriteRoutes: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getSFUConfigWithOpenID(
|
||||||
|
matrixClient,
|
||||||
|
ownMemberMock,
|
||||||
|
"https://sfu.example.org",
|
||||||
|
"!example_room_id",
|
||||||
|
{
|
||||||
|
delayEndpointBaseUrl: "https://homeserverserver.org/cs_api",
|
||||||
|
delayId: "mock_delay_id",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prüfe das Ergebnis
|
||||||
|
expect(config).toMatchObject({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: sfuUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
});
|
||||||
|
|
||||||
it("should try legacy and then new endpoint with delay delegation", async () => {
|
it("should try legacy and then new endpoint with delay delegation", async () => {
|
||||||
fetchMock.post("https://sfu.example.org/get_token", () => {
|
fetchMock.post("https://sfu.example.org/get_token", () => {
|
||||||
return {
|
return {
|
||||||
@@ -121,7 +214,7 @@ describe("getSFUConfigWithOpenID", () => {
|
|||||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||||
expect(calls[0][1]).toStrictEqual({
|
expect(calls[0][1]).toStrictEqual({
|
||||||
// check if it uses correct delayID!
|
// check if it uses correct delayID!
|
||||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -131,7 +224,7 @@ describe("getSFUConfigWithOpenID", () => {
|
|||||||
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
|
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
|
||||||
|
|
||||||
expect(calls[1][1]).toStrictEqual({
|
expect(calls[1][1]).toStrictEqual({
|
||||||
body: '{"room":"!example_room_id","device_id":"DEVICE"}',
|
body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
@@ -176,7 +269,7 @@ describe("getSFUConfigWithOpenID", () => {
|
|||||||
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
|
||||||
expect(calls[0][1]).toStrictEqual({
|
expect(calls[0][1]).toStrictEqual({
|
||||||
// check if it uses correct delayID!
|
// check if it uses correct delayID!
|
||||||
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export async function getSFUConfigWithOpenID(
|
|||||||
serviceUrl,
|
serviceUrl,
|
||||||
roomId,
|
roomId,
|
||||||
openIdToken,
|
openIdToken,
|
||||||
|
opts?.delayEndpointBaseUrl,
|
||||||
|
opts?.delayId,
|
||||||
);
|
);
|
||||||
logger?.info(`Got JWT from call's active focus URL.`);
|
logger?.info(`Got JWT from call's active focus URL.`);
|
||||||
return extractFullConfigFromToken(sfuConfig);
|
return extractFullConfigFromToken(sfuConfig);
|
||||||
@@ -187,20 +189,62 @@ async function getLiveKitJWT(
|
|||||||
livekitServiceURL: string,
|
livekitServiceURL: string,
|
||||||
matrixRoomId: string,
|
matrixRoomId: string,
|
||||||
openIDToken: IOpenIDToken,
|
openIDToken: IOpenIDToken,
|
||||||
|
delayEndpointBaseUrl?: string,
|
||||||
|
delayId?: string,
|
||||||
): Promise<{ url: string; jwt: string }> {
|
): Promise<{ url: string; jwt: string }> {
|
||||||
const res = await doNetworkOperationWithRetry(async () => {
|
interface IDelayParams {
|
||||||
|
delay_id?: string;
|
||||||
|
delay_timeout?: number;
|
||||||
|
delay_cs_api_url?: string;
|
||||||
|
}
|
||||||
|
let bodyDalayParts: IDelayParams = {};
|
||||||
|
// Also check for empty string
|
||||||
|
if (delayId && delayEndpointBaseUrl) {
|
||||||
|
const delayTimeoutMs =
|
||||||
|
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
|
||||||
|
bodyDalayParts = {
|
||||||
|
delay_id: delayId,
|
||||||
|
delay_timeout: delayTimeoutMs,
|
||||||
|
delay_cs_api_url: delayEndpointBaseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
|
||||||
return await fetch(livekitServiceURL + "/sfu/get", {
|
return await fetch(livekitServiceURL + "/sfu/get", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
// This is the actual livekit room alias. For the legacy jwt endpoint simply the room id was used.
|
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
|
||||||
|
// However, the livekit room alias is provided as part of the JWT payload.
|
||||||
room: matrixRoomId,
|
room: matrixRoomId,
|
||||||
openid_token: openIDToken,
|
openid_token: openIDToken,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
|
...delayParts,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await doNetworkOperationWithRetry(async () => {
|
||||||
|
let response = await makeRequest(bodyDalayParts);
|
||||||
|
|
||||||
|
// Old service compatibility check
|
||||||
|
const oldServiceDoesNotSupportDelayParts =
|
||||||
|
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
|
||||||
|
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
|
||||||
|
if (oldServiceDoesNotSupportDelayParts) {
|
||||||
|
try {
|
||||||
|
const errorBody = await response.json();
|
||||||
|
if (errorBody.errcode === "M_BAD_JSON") {
|
||||||
|
response = await makeRequest({});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't parse the error, treat as real error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -241,7 +285,7 @@ export async function getLiveKitJWTWithDelayDelegation(
|
|||||||
// Also check for empty string
|
// Also check for empty string
|
||||||
if (delayId && delayEndpointBaseUrl) {
|
if (delayId && delayEndpointBaseUrl) {
|
||||||
const delayTimeoutMs =
|
const delayTimeoutMs =
|
||||||
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms ?? 1000;
|
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
|
||||||
bodyDalayParts = {
|
bodyDalayParts = {
|
||||||
delay_id: delayId,
|
delay_id: delayId,
|
||||||
delay_timeout: delayTimeoutMs,
|
delay_timeout: delayTimeoutMs,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
padding: 2px;
|
|
||||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
|
||||||
background: var(--cpd-color-bg-canvas-default);
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input {
|
|
||||||
appearance: none;
|
|
||||||
/* Safari puts a margin on these, which is not removed via appearance: none */
|
|
||||||
margin: 0;
|
|
||||||
block-size: var(--cpd-space-11x);
|
|
||||||
inline-size: var(--cpd-space-11x);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--cpd-radius-pill-effect);
|
|
||||||
background: var(--cpd-color-bg-action-secondary-rest);
|
|
||||||
box-shadow: var(--small-drop-shadow);
|
|
||||||
transition: background-color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle svg {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
padding: calc(2.5 * var(--cpd-space-1x));
|
|
||||||
pointer-events: none;
|
|
||||||
color: var(--cpd-color-icon-primary);
|
|
||||||
transition: color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle svg:nth-child(2) {
|
|
||||||
inset-inline-start: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle svg:nth-child(4) {
|
|
||||||
inset-inline-end: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.toggle input:hover {
|
|
||||||
background: var(--cpd-color-bg-action-secondary-hovered);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:active {
|
|
||||||
background: var(--cpd-color-bg-action-secondary-pressed);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:checked {
|
|
||||||
background: var(--cpd-color-bg-action-primary-rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:checked + svg {
|
|
||||||
color: var(--cpd-color-icon-on-solid-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
.toggle input:checked:hover {
|
|
||||||
background: var(--cpd-color-bg-action-primary-hovered);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:checked:active {
|
|
||||||
background: var(--cpd-color-bg-action-primary-pressed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle input:first-child {
|
|
||||||
margin-inline-end: 5px;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/*
|
|
||||||
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 { fn } from "storybook/test";
|
|
||||||
|
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { LayoutToggle } from "./LayoutToggle";
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
component: LayoutToggle,
|
|
||||||
} satisfies Meta<typeof LayoutToggle>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
layout: "grid",
|
|
||||||
setLayout: fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ChangeEvent, type FC, useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Tooltip } from "@vector-im/compound-web";
|
|
||||||
import {
|
|
||||||
SpotlightIcon,
|
|
||||||
GridIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
import styles from "./LayoutToggle.module.css";
|
|
||||||
|
|
||||||
export type Layout = "spotlight" | "grid";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
layout: Layout;
|
|
||||||
setLayout: (layout: Layout) => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const onChange = useCallback(
|
|
||||||
(e: ChangeEvent<HTMLInputElement>) => setLayout(e.target.value as Layout),
|
|
||||||
[setLayout],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className={classNames(styles.toggle, className)}>
|
|
||||||
<Tooltip label={t("layout_spotlight_label")}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="layout"
|
|
||||||
value="spotlight"
|
|
||||||
checked={layout === "spotlight"}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
|
||||||
<Tooltip label={t("layout_grid_label")}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="layout"
|
|
||||||
value="grid"
|
|
||||||
checked={layout === "grid"}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<GridIcon aria-hidden width={24} height={24} />
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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"
|
||||||
@@ -396,21 +396,23 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<fieldset
|
||||||
class="toggle layout"
|
aria-label="Layout"
|
||||||
|
class="_toggle_13rnk_9 layout"
|
||||||
|
data-size="lg"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
aria-labelledby="_r_11_"
|
aria-labelledby="_r_11_"
|
||||||
name="layout"
|
name="layoutMode"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="spotlight"
|
value="spotlight"
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="24"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
width="24"
|
width="1em"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
@@ -420,23 +422,23 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
<input
|
<input
|
||||||
aria-labelledby="_r_16_"
|
aria-labelledby="_r_16_"
|
||||||
checked=""
|
checked=""
|
||||||
name="layout"
|
name="layoutMode"
|
||||||
type="radio"
|
type="radio"
|
||||||
value="grid"
|
value="grid"
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
height="24"
|
height="1em"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
width="24"
|
width="1em"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</form>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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:
|
||||||
@@ -96,19 +97,20 @@ describe("createHomeserverConnected$", () => {
|
|||||||
|
|
||||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||||
// easy enough to read them so I think they can stay.
|
// easy enough to read them so I think they can stay.
|
||||||
|
// Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests
|
||||||
it("is false when sync state is not Syncing", () => {
|
it("is false when sync state is not Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// Make sync loop OK
|
// Make sync loop OK
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
// Indicate probable leave before connection
|
// Indicate probable leave before connection
|
||||||
@@ -118,7 +120,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("becomes true only when all three conditions are satisfied", () => {
|
it("becomes true only when all three conditions are satisfied", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// 1. Sync loop connected
|
// 1. Sync loop connected
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
||||||
@@ -128,7 +130,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when sync loop leaves Syncing", () => {
|
it("drops back to false when sync loop leaves Syncing", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
// Reach connected state
|
// Reach connected state
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
@@ -140,7 +142,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when membership status becomes disconnected", () => {
|
it("drops back to false when membership status becomes disconnected", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -150,7 +152,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -160,7 +162,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected.combined$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
@@ -174,7 +176,7 @@ describe("createHomeserverConnected$", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("composite sequence reflects each individual failure reason", () => {
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
const hsConnected = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session, 0);
|
||||||
|
|
||||||
// Initially false (sync error + disconnected + not probably left)
|
// Initially false (sync error + disconnected + not probably left)
|
||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
@@ -200,3 +202,62 @@ describe("createHomeserverConnected$", () => {
|
|||||||
expect(hsConnected.combined$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createHomeserverConnected$ - Grace Period", () => {
|
||||||
|
const GRACE_PERIOD = 5;
|
||||||
|
|
||||||
|
function marbleTest(
|
||||||
|
syncStateMarbles: string,
|
||||||
|
expectedConnectedMarbles: string,
|
||||||
|
): void {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const syncState$ = behavior(syncStateMarbles, {
|
||||||
|
s: SyncState.Syncing,
|
||||||
|
e: SyncState.Error,
|
||||||
|
});
|
||||||
|
const client = new MockMatrixClient(syncState$.value);
|
||||||
|
schedule(syncStateMarbles, {
|
||||||
|
s: () => client.setSyncState(SyncState.Syncing),
|
||||||
|
e: () => client.setSyncState(SyncState.Error),
|
||||||
|
});
|
||||||
|
const session = new MockMatrixRTCSession({
|
||||||
|
membershipStatus: Status.Connected,
|
||||||
|
probablyLeft: false,
|
||||||
|
});
|
||||||
|
const hsConnected = createHomeserverConnected$(
|
||||||
|
testScope(),
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
GRACE_PERIOD,
|
||||||
|
);
|
||||||
|
expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, {
|
||||||
|
y: true,
|
||||||
|
n: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("respects gracePeriodMs: stays true during grace period and flips false after", () => {
|
||||||
|
// - Initial state: Everything is connected
|
||||||
|
// - Sync error occurs -> should remain connected due to grace period
|
||||||
|
// - After grace period, not connected
|
||||||
|
marbleTest("se", "y-----n");
|
||||||
|
// If the sync error takes longer to occur, it should take equally long for
|
||||||
|
// the connection state to change
|
||||||
|
marbleTest("s--e", "y-------n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers immediately if sync returns during grace period", () => {
|
||||||
|
// - Initial state: Connected
|
||||||
|
// - Sync error occurs
|
||||||
|
// - Sync recovers BEFORE the grace period expires
|
||||||
|
// - Connection state remains constant
|
||||||
|
marbleTest("se--s", "y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips to true IMMEDIATELY even if a grace period was pending", () => {
|
||||||
|
// - Initial error: connection eventually flips to false
|
||||||
|
// - Back to Syncing -> Must be connected immediately (synchronously)
|
||||||
|
marbleTest("e-----s", "y----ny");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,9 +12,20 @@ import {
|
|||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||||
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
|
import {
|
||||||
|
fromEvent,
|
||||||
|
startWith,
|
||||||
|
map,
|
||||||
|
tap,
|
||||||
|
type Observable,
|
||||||
|
distinctUntilChanged,
|
||||||
|
switchMap,
|
||||||
|
of,
|
||||||
|
delay,
|
||||||
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { Config } from "../../../config/Config";
|
||||||
import { type ObservableScope } from "../../ObservableScope";
|
import { type ObservableScope } from "../../ObservableScope";
|
||||||
import { type Behavior } from "../../Behavior";
|
import { type Behavior } from "../../Behavior";
|
||||||
import { and$ } from "../../../utils/observable";
|
import { and$ } from "../../../utils/observable";
|
||||||
@@ -35,28 +46,46 @@ export interface HomeserverConnected {
|
|||||||
* for the purposes of a MatrixRTC session.
|
* for the purposes of a MatrixRTC session.
|
||||||
*
|
*
|
||||||
* Becomes FALSE if ANY sub-condition is fulfilled:
|
* Becomes FALSE if ANY sub-condition is fulfilled:
|
||||||
* 1. Sync loop is not in SyncState.Syncing
|
* 1. Sync loop is not in SyncState.Syncing (after grace period)
|
||||||
* 2. membershipStatus !== Status.Connected
|
* 2. membershipStatus !== Status.Connected
|
||||||
* 3. probablyLeft === true
|
* 3. probablyLeft === true
|
||||||
|
*
|
||||||
|
* @param scope - The observable scope for lifecycle management.
|
||||||
|
* @param client - The Matrix client to monitor sync state.
|
||||||
|
* @param matrixRTCSession - The RTC session to monitor membership.
|
||||||
|
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
|
||||||
|
* If not provided, uses the config value (default 10000ms).
|
||||||
*/
|
*/
|
||||||
export function createHomeserverConnected$(
|
export function createHomeserverConnected$(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
matrixRTCSession: NodeStyleEventEmitter &
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
|
gracePeriodMs?: number,
|
||||||
): HomeserverConnected {
|
): HomeserverConnected {
|
||||||
|
// Get grace period from parameter or config (default 10000ms)
|
||||||
|
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
|
||||||
|
|
||||||
const syncing$ = (
|
const syncing$ = (
|
||||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||||
).pipe(
|
).pipe(
|
||||||
startWith([client.getSyncState()]),
|
startWith([client.getSyncState()]),
|
||||||
map(([state]) => state === SyncState.Syncing),
|
map(([state]) => state === SyncState.Syncing),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((isSyncing) => {
|
||||||
|
if (isSyncing || graceMs <= 0) {
|
||||||
|
return of(isSyncing);
|
||||||
|
}
|
||||||
|
return of(false).pipe(delay(graceMs), startWith(true));
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rtsSession$ = scope.behavior<Status>(
|
const rtsSession$ = scope.behavior<Status>(
|
||||||
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(
|
||||||
|
|||||||
@@ -778,6 +778,19 @@ export function enterRTCSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER:
|
||||||
|
// - The /sync loop is unresponsive for > `gracePeriod` ms, or
|
||||||
|
// - A delayed leave event is emitted (after `leaveDelay` ms period).
|
||||||
|
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
|
||||||
|
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
|
||||||
|
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
|
||||||
|
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
|
||||||
|
|
||||||
|
// Math.min is used to account for the respective worst case: /sync not available or leave event emitted.
|
||||||
|
const maxWaitTime = Math.min(gracePeriod, leaveDelay);
|
||||||
|
const maximumNetworkErrorRetryCount =
|
||||||
|
Math.ceil(maxWaitTime / retryInterval) + 1;
|
||||||
|
|
||||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||||
|
|
||||||
@@ -803,6 +816,7 @@ export function enterRTCSession(
|
|||||||
membershipEventExpiryMs:
|
membershipEventExpiryMs:
|
||||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
|
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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