Merge branch 'livekit' into hs/compound-switch

This commit is contained in:
Robin
2026-05-11 09:36:08 +02:00
31 changed files with 1322 additions and 822 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

@@ -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,9 +142,7 @@ export class TestHelpers {
timeout: 15000, timeout: 15000,
}); });
await this.maybeDismissBrowserNotSupportedToast(page); await this.dismissStartupToasts(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await this.maybeDismissBackupChat(page);
await TestHelpers.setDevToolElementCallDevUrl(page); await TestHelpers.setDevToolElementCallDevUrl(page);
@@ -133,73 +153,39 @@ export class TestHelpers {
return { page, clientHandle, mxId }; return { page, clientHandle, mxId };
} }
private static async maybeDismissBrowserNotSupportedToast( // Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
page: Page, // Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
): Promise<void> { // dismiss (if part of expected toats)
const browserUnsupportedToast = page public static async dismissStartupToasts(page: Page): Promise<void> {
.getByText("Element does not support this browser") const expectedToasts = [
.locator("..") { title: "Failed to load service worker", button: "OK" },
.locator(".."); { title: "Back up your chats", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
// Dismiss incompatible browser toast const toast = page.locator(".mx_Toast_toast");
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
}
private static async maybeDismissServiceWorkerWarningToast( // eslint-disable-next-line no-constant-condition
page: Page, while (true) {
): Promise<void> { try {
const toast = page await toast.waitFor({ state: "visible", timeout: 700 });
.locator(".mx_Toast_toast") const title = await toast.locator(".mx_Toast_title h2").textContent();
.getByText("Failed to load service worker");
try { // Find the matching toast config
await expect(toast).toBeVisible({ timeout: 700 }); const toastConfig = expectedToasts.find((t) =>
await page title?.includes(t.title),
.locator(".mx_Toast_toast") );
.getByRole("button", { name: "OK" })
.click();
} catch {
// toast not visible, continue as normal
}
}
private static async maybeDismissBackupChat(page: Page): Promise<void> { if (toastConfig) {
const toast = page await toast.getByRole("button", { name: toastConfig.button }).click();
.locator(".mx_Toast_toast") } else {
.getByText("Back up your chats"); // Unknown toast. We don't want to act on unknown toasts
break;
try { }
await expect(toast).toBeVisible({ timeout: 700 }); } catch {
await page // No toast visible, exit loop
.locator(".mx_Toast_toast") break;
.getByRole("button", { name: "Dismiss" }) }
.click();
} catch {
// toast not visible, continue as normal
}
}
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Back up your chats");
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "Dismiss" })
.click();
} catch {
// toast not visible, continue as normal
} }
} }
@@ -222,7 +208,7 @@ export class TestHelpers {
timeout: 10000, timeout: 10000,
}); });
await expect(page.getByText("Encryption enabled")).toBeVisible(); await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page); await TestHelpers.dismissStartupToasts(page);
// Invite users if any // Invite users if any
if (andInvite.length > 0) { if (andInvite.length > 0) {
@@ -261,7 +247,7 @@ export class TestHelpers {
await expect( await expect(
page.getByRole("main").getByRole("heading", { name: roomName }), page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible(); ).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page); await TestHelpers.dismissStartupToasts(page);
} }
/** /**

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"]'),

1440
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

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

@@ -157,6 +157,8 @@ export const UnavailableMediaDevices: Story = {
...Default, ...Default,
args: { args: {
...Default.args, ...Default.args,
audioEnabled: false,
videoEnabled: false,
toggleAudio: undefined, toggleAudio: undefined,
toggleVideo: undefined, toggleVideo: undefined,
audioOutputSwitcher: undefined, audioOutputSwitcher: undefined,

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

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

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

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

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

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