Merge branch 'livekit' into valere/devx/livekit_logs

This commit is contained in:
Valere
2026-05-11 17:28:07 +02:00
50 changed files with 2133 additions and 1040 deletions

View File

@@ -85,3 +85,31 @@ jobs:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_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

View File

@@ -17,7 +17,7 @@ on:
package:
required: true
type: string
description: Which package to deploy - 'full', 'embedded', or 'sdk'
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
artifact_run_id:
required: false
type: string
@@ -43,7 +43,7 @@ jobs:
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
env: ${{ inputs.package}}
ref: ${{ inputs.deployment_ref }}
desc: |
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
# 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
- name: Add config file
# Not needed for storybook deployments
if: inputs.package != 'storybook'
run: |
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
@@ -78,7 +82,7 @@ jobs:
with:
publish-dir: webapp
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:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -14,7 +14,7 @@ jobs:
# 2. Event must be a pull_request
# 3. Head repository must be the SAME as the base repository (No Forks!)
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
@@ -63,6 +63,24 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
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:
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
needs: prdetails

View File

@@ -42,8 +42,6 @@ server {
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# Synapse reverse proxy including .well-known/matrix/client
@@ -91,8 +89,6 @@ server {
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# MatrixRTC reverse proxy
@@ -144,8 +140,6 @@ server {
proxy_pass http://livekit-sfu:7880/;
}
error_page 500 502 503 504 /50x.html;
}
# MatrixRTC reverse proxy
@@ -192,8 +186,6 @@ server {
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
@@ -243,7 +235,6 @@ server {
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
@@ -309,6 +298,4 @@ server {
}
error_page 500 502 503 504 /50x.html;
}

View File

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

View File

@@ -145,6 +145,7 @@
},
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
"layout_switch_label": "Layout",
"lobby": {
"ask_to_join": "Request to join call",
"join_as_guest": "Join as guest",

View File

@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
@@ -153,7 +153,7 @@
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1",
"esbuild": "^0.27.7"
"esbuild": "^0.28.0"
}
},
"packageManager": "pnpm@10.33.0"

View File

@@ -54,12 +54,34 @@ export class TestHelpers {
.click();
}
public static async joinCallInCurrentDM(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, true);
}
public static async joinCallInCurrentRoom(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
// This is the header button that notifies about an ongoing call
const label = audioOnly ? "Voice call started" : "Video call started";
await this.joinCallInRoom(page, audioOnly, false);
}
public static async joinCallInRoom(
page: Page,
audioOnly: boolean = false,
isDM: boolean = false,
): Promise<void> {
// XXX This using the notification toast to join the room.
// Not the button in the header
await page.waitForTimeout(3000);
const label = isDM
? audioOnly
? "Incoming voice call"
: "Incoming video call"
: "Group call started";
await expect(page.getByText(label)).toBeVisible({
timeout: 10000,
});
@@ -120,8 +142,7 @@ export class TestHelpers {
timeout: 15000,
});
await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await this.dismissStartupToasts(page);
await TestHelpers.setDevToolElementCallDevUrl(page);
@@ -132,57 +153,39 @@ export class TestHelpers {
return { page, clientHandle, mxId };
}
private static async maybeDismissBrowserNotSupportedToast(
page: Page,
): Promise<void> {
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
// Dismiss incompatible browser 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
}
}
const toast = page.locator(".mx_Toast_toast");
private static async maybeDismissServiceWorkerWarningToast(
page: Page,
): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Failed to load service worker");
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await toast.waitFor({ state: "visible", timeout: 700 });
const title = await toast.locator(".mx_Toast_title h2").textContent();
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "OK" })
.click();
} catch {
// toast not visible, continue as normal
}
}
// Find the matching toast config
const toastConfig = expectedToasts.find((t) =>
title?.includes(t.title),
);
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
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
}
} catch {
// No toast visible, exit loop
break;
}
}
}
@@ -205,7 +208,7 @@ export class TestHelpers {
timeout: 10000,
});
await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
await TestHelpers.dismissStartupToasts(page);
// Invite users if any
if (andInvite.length > 0) {
@@ -244,7 +247,7 @@ export class TestHelpers {
await expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
await TestHelpers.dismissStartupToasts(page);
}
/**

View File

@@ -41,7 +41,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
@@ -132,7 +132,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),

1450
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {
...Default,
args: {
@@ -157,6 +175,8 @@ export const UnavailableMediaDevices: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined,
toggleVideo: undefined,
audioOutputSwitcher: undefined,

View File

@@ -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 classNames from "classnames";
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 LogoType from "../icons/LogoType.svg?react";
@@ -23,8 +29,11 @@ import {
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
import { LayoutToggle } from "../room/LayoutToggle";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -74,6 +83,13 @@ export interface FooterProps {
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
audioOptions?: MenuOptions[];
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
}
export const CallFooter: FC<FooterProps> = ({
@@ -99,9 +115,16 @@ export const CallFooter: FC<FooterProps> = ({
hangup,
debugTileLayout,
tileStoreGeneration,
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "sm" : "lg";
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
@@ -120,24 +143,58 @@ export const CallFooter: FC<FooterProps> = ({
);
}
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
/>,
);
} else {
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) {
buttons.push(
@@ -232,10 +289,18 @@ export const CallFooter: FC<FooterProps> = ({
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{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}
layout={layoutMode}
setLayout={setLayoutMode}
value={layoutMode}
onChange={setLayoutMode}
/>
)}
</div>

View 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);
}
}

View 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",
},
};

View 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);
});
});

View 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>
);
};

View File

@@ -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>
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,99 @@ describe("getSFUConfigWithOpenID", () => {
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 () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
@@ -121,7 +214,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// 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",
headers: {
"Content-Type": "application/json",
@@ -131,7 +224,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
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: {
"Content-Type": "application/json",
},
@@ -176,7 +269,7 @@ describe("getSFUConfigWithOpenID", () => {
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// 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",
headers: {
"Content-Type": "application/json",

View File

@@ -155,6 +155,8 @@ export async function getSFUConfigWithOpenID(
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
@@ -187,20 +189,62 @@ async function getLiveKitJWT(
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: 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", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
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,
openid_token: openIDToken,
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) {
@@ -241,7 +285,7 @@ export async function getLiveKitJWTWithDelayDelegation(
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms ?? 1000;
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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(),
},
};

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

@@ -147,9 +147,9 @@ exports[`InCallView > rendering > renders 1`] = `
Only works while using app
</p>
<button
class="_button_13vu4_8"
class="_button_1nw83_8"
data-kind="primary"
data-size="sm"
data-size="md"
role="button"
tabindex="0"
>
@@ -305,7 +305,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-labelledby="_r_d_"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
@@ -329,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
@@ -353,7 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_n_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
@@ -375,7 +375,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-labelledby="_r_s_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
@@ -396,21 +396,23 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
</div>
<form
class="toggle layout"
<fieldset
aria-label="Layout"
class="_toggle_13rnk_9 layout"
data-size="lg"
>
<input
aria-labelledby="_r_11_"
name="layout"
name="layoutMode"
type="radio"
value="spotlight"
/>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
height="1em"
viewBox="0 0 24 24"
width="24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
@@ -420,23 +422,23 @@ exports[`InCallView > rendering > renders 1`] = `
<input
aria-labelledby="_r_16_"
checked=""
name="layout"
name="layoutMode"
type="radio"
value="grid"
/>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
height="1em"
viewBox="0 0 24 24"
width="24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<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"
/>
</svg>
</form>
</fieldset>
</div>
</div>
</div>

View File

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

View File

@@ -12,9 +12,20 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { fromEvent, startWith, map, tap, type Observable } from "rxjs";
import {
fromEvent,
startWith,
map,
tap,
type Observable,
distinctUntilChanged,
switchMap,
of,
delay,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { Config } from "../../../config/Config";
import { type ObservableScope } from "../../ObservableScope";
import { type Behavior } from "../../Behavior";
import { and$ } from "../../../utils/observable";
@@ -35,28 +46,46 @@ export interface HomeserverConnected {
* for the purposes of a MatrixRTC session.
*
* Becomes FALSE if ANY sub-condition is fulfilled:
* 1. Sync loop is not in SyncState.Syncing
* 1. Sync loop is not in SyncState.Syncing (after grace period)
* 2. membershipStatus !== Status.Connected
* 3. probablyLeft === true
*
* @param scope - The observable scope for lifecycle management.
* @param client - The Matrix client to monitor sync state.
* @param matrixRTCSession - The RTC session to monitor membership.
* @param gracePeriodMs - Grace period in milliseconds to wait before reporting sync disconnect.
* If not provided, uses the config value (default 10000ms).
*/
export function createHomeserverConnected$(
scope: ObservableScope,
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
matrixRTCSession: NodeStyleEventEmitter &
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
gracePeriodMs?: number,
): HomeserverConnected {
// Get grace period from parameter or config (default 10000ms)
const graceMs = gracePeriodMs ?? Config.get().sync_disconnect_grace_period_ms;
const syncing$ = (
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
).pipe(
startWith([client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
distinctUntilChanged(),
switchMap((isSyncing) => {
if (isSyncing || graceMs <= 0) {
return of(isSyncing);
}
return of(false).pipe(delay(graceMs), startWith(true));
}),
distinctUntilChanged(),
);
const rtsSession$ = scope.behavior<Status>(
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
),
Status.Unknown,
matrixRTCSession.membershipStatus ?? Status.Unknown,
);
const membershipConnected$ = rtsSession$.pipe(

View File

@@ -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.
// TODO where/how do we track errors originating from the ongoing rtcSession?
@@ -803,6 +816,7 @@ export function enterRTCSession(
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
},
);
}

View File

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

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { type FC, useRef, useState } from "react";
import { type FC, useState } from "react";
import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
@@ -39,9 +39,7 @@ const TestComponent: FC<TestComponentProps> = ({
initialModalOpen = false,
}) => {
const [modalOpen, setModalOpen] = useState(initialModalOpen);
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
ref,
() => {},
() => {},
setAudioEnabled,
@@ -49,8 +47,11 @@ const TestComponent: FC<TestComponentProps> = ({
toggleHandRaised,
);
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 && (
<dialog
open
@@ -64,7 +65,7 @@ const TestComponent: FC<TestComponentProps> = ({
<button>InModalButton</button>
</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
// events. <video> is kind of a weird choice, but it'll do the job.
render(
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
>
<TestComponent setAudioEnabled={() => {}} />
</video>,
<div id="root">
<video
tabIndex={0}
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
/>
<TestComponent setAudioEnabled={() => {}} />,
</div>,
);
await user.tab(); // Focus the <video>

View File

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