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_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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1450
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -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(

View File

@@ -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"] {

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

@@ -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,

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

View File

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

View File

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

View File

@@ -147,9 +147,9 @@ exports[`InCallView > rendering > renders 1`] = `
Only works while using app Only works while using app
</p> </p>
<button <button
class="_button_13vu4_8" class="_button_1nw83_8"
data-kind="primary" data-kind="primary"
data-size="sm" data-size="md"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@@ -305,7 +305,7 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<button <button
aria-labelledby="_r_d_" aria-labelledby="_r_d_"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
data-testid="settings-bottom-center" data-testid="settings-bottom-center"
@@ -329,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_i_" aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_mute" data-testid="incall_mute"
@@ -353,7 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_n_" aria-labelledby="_r_n_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_videomute" data-testid="incall_videomute"
@@ -375,7 +375,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button> </button>
<button <button
aria-labelledby="_r_s_" aria-labelledby="_r_s_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110" class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
data-testid="incall_leave" data-testid="incall_leave"
@@ -396,21 +396,23 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
</div> </div>
<form <fieldset
class="toggle layout" aria-label="Layout"
class="_toggle_13rnk_9 layout"
data-size="lg"
> >
<input <input
aria-labelledby="_r_11_" aria-labelledby="_r_11_"
name="layout" name="layoutMode"
type="radio" type="radio"
value="spotlight" value="spotlight"
/> />
<svg <svg
aria-hidden="true" aria-hidden="true"
fill="currentColor" fill="currentColor"
height="24" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
width="24" width="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
@@ -420,23 +422,23 @@ exports[`InCallView > rendering > renders 1`] = `
<input <input
aria-labelledby="_r_16_" aria-labelledby="_r_16_"
checked="" checked=""
name="layout" name="layoutMode"
type="radio" type="radio"
value="grid" value="grid"
/> />
<svg <svg
aria-hidden="true" aria-hidden="true"
fill="currentColor" fill="currentColor"
height="24" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
width="24" width="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z" d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
/> />
</svg> </svg>
</form> </fieldset>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -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(

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. // 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,
}, },
); );
} }

View File

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

View File

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

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. 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],
), ),
); );