mirror of
https://github.com/vector-im/element-call.git
synced 2026-07-18 18:59:23 +00:00
Merge branch 'livekit' into hs/compound-switch
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>
|
||||||
|
|||||||
@@ -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,9 +142,7 @@ export class TestHelpers {
|
|||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.maybeDismissBrowserNotSupportedToast(page);
|
await this.dismissStartupToasts(page);
|
||||||
await this.maybeDismissServiceWorkerWarningToast(page);
|
|
||||||
await this.maybeDismissBackupChat(page);
|
|
||||||
|
|
||||||
await TestHelpers.setDevToolElementCallDevUrl(page);
|
await TestHelpers.setDevToolElementCallDevUrl(page);
|
||||||
|
|
||||||
@@ -133,73 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async maybeDismissBackupChat(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
|
|
||||||
const toast = page
|
|
||||||
.locator(".mx_Toast_toast")
|
|
||||||
.getByText("Back up your chats");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(toast).toBeVisible({ timeout: 700 });
|
|
||||||
await page
|
|
||||||
.locator(".mx_Toast_toast")
|
|
||||||
.getByRole("button", { name: "Dismiss" })
|
|
||||||
.click();
|
|
||||||
} catch {
|
|
||||||
// toast not visible, continue as normal
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,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) {
|
||||||
@@ -261,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"]'),
|
||||||
|
|||||||
1440
pnpm-lock.yaml
generated
1440
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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
grid-template-areas: ". buttons layout";
|
grid-template-areas: ". buttons layout";
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--cpd-space-3x);
|
gap: var(--cpd-space-3x);
|
||||||
padding: var(--cpd-space-10x) var(--cpd-space-6x);
|
/* Ensure that footer lies within the safe area */
|
||||||
|
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
|
||||||
|
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
|
||||||
|
padding-block: var(--cpd-space-10x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-10x));
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
@@ -118,13 +122,15 @@ Once we exceed 500 we hide everything except the buttons.
|
|||||||
|
|
||||||
@media (max-height: 800px) {
|
@media (max-height: 800px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-8x);
|
padding-block: var(--cpd-space-8x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 400px) {
|
@media (max-height: 400px) {
|
||||||
.footer {
|
.footer {
|
||||||
padding-block: var(--cpd-space-4x);
|
padding-block: var(--cpd-space-4x)
|
||||||
|
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +146,9 @@ Once we exceed 500 we hide everything except the buttons.
|
|||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
padding-block-start: var(--cpd-space-3x);
|
padding-block-start: var(--cpd-space-3x);
|
||||||
padding-block-end: var(--cpd-space-2x);
|
padding-block-end: calc(
|
||||||
|
env(safe-area-inset-bottom) + var(--cpd-space-2x)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export const UnavailableMediaDevices: Story = {
|
|||||||
...Default,
|
...Default,
|
||||||
args: {
|
args: {
|
||||||
...Default.args,
|
...Default.args,
|
||||||
|
audioEnabled: false,
|
||||||
|
videoEnabled: false,
|
||||||
toggleAudio: undefined,
|
toggleAudio: undefined,
|
||||||
toggleVideo: undefined,
|
toggleVideo: undefined,
|
||||||
audioOutputSwitcher: undefined,
|
audioOutputSwitcher: undefined,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -227,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||||
|
|
||||||
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
|
||||||
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
|
||||||
useCallViewKeyboardShortcuts(
|
useCallViewKeyboardShortcuts(
|
||||||
containerRef1,
|
|
||||||
toggleAudio,
|
toggleAudio,
|
||||||
toggleVideo,
|
toggleVideo,
|
||||||
setAudioEnabled,
|
setAudioEnabled,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { usePageTitle } from "../usePageTitle";
|
|||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
import { CallFooter } from "../components/CallFooter";
|
import { CallFooter } from "../components/CallFooter";
|
||||||
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -91,6 +92,11 @@ export const LobbyView: FC<Props> = ({
|
|||||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||||
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
|
||||||
|
|
||||||
|
// This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
||||||
|
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
||||||
|
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
|
||||||
|
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
|
||||||
|
|
||||||
const openSettings = useCallback(
|
const openSettings = useCallback(
|
||||||
() => setSettingsModalOpen(true),
|
() => setSettingsModalOpen(true),
|
||||||
[setSettingsModalOpen],
|
[setSettingsModalOpen],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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