Compare commits

..

13 Commits

Author SHA1 Message Date
Timo K
768cf6f8ef Update embedded delayed event configuration. 2025-12-02 16:37:38 +01:00
Timo
2281fd65e6 Update livekit-client to disable ScriptTransform for Chromium based browsers (#3543)
* Update livekit-client to disable ScriptTransform for Chromium based browsers

See: https://github.com/livekit/client-sdk-js/pull/1703
Signed-off-by: Timo K <toger5@hotmail.de>

* bump playwright

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-23 15:38:50 +02:00
Will Hunt
0f77b1c42d Add media hints for notification events. (#3493) (#3514)
* Add media hints for notification events.

* Prevent showing calling view when disconnected from Livekit. (#3491)

* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie

* Update dependency livekit-client to v2.15.7 (#3496)



* Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)

* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints



* fix test



* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------






* Log when a track is unpublished or runs into an error (#3495)

* default mute states (unmuted!) in widget mode (embedded + intent) (#3494)

* default mute states (unmuted!) in widget mode (embedded + intent)



* review



* introduce a cache for the url params.



* Add an option to skip the cache.



---------



* Apply new hint code

* missed a bit

* fix intent

* Automatically update intent on mute change

* update packages

* lint

* Fix tests

* fix merge fails

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-25 18:44:45 +02:00
Timo
0a286b4276 Never skip lobby for huddles (#3508) (#3509)
* Never skip lobby for huddles



---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 12:00:45 +02:00
fkwp
cb7fc44fdf Backport: Misc/android allow rcs to be published as non snapshot versions (#3506)
* Android: allow RCs to be published as non-SNAPSHOT versions

* Fix broken string in build script

---------

Co-authored-by: Jorge Martín <jorgem@element.io>
2025-09-19 22:19:49 +02:00
Timo
93a1a6a3be Backport: Fix: never stop ring feedback on the sender side (#3503)
* make ring$ a behavior and add code comments to justify/explain the change.

Signed-off-by: Timo K <toger5@hotmail.de>

* Add test: reproduce "ring does not stop" race.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 16:29:50 +02:00
Timo
d7824ce86e Backport: Add ring notification to UserIntent.StartNewCallDM (#3498)
* Add ring notification to UserIntent.StartNewCallDM

Signed-off-by: Timo K <toger5@hotmail.de>

* Add more tests (refactor to compute + get)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 17:46:04 +02:00
Timo
db5c7cf9c7 default mute states (unmuted!) in widget mode (embedded + intent) (#3494)
* default mute states (unmuted!) in widget mode (embedded + intent)

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* introduce a cache for the url params.

Signed-off-by: Timo K <toger5@hotmail.de>

* Add an option to skip the cache.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 13:21:12 +02:00
Will Hunt
63122c7f6b Log when a track is unpublished or runs into an error (#3495) 2025-09-18 12:09:54 +01:00
Robin
4be395500f Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)
* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-18 12:58:47 +02:00
renovate[bot]
df7bd8ff2b Update dependency livekit-client to v2.15.7 (#3496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-18 11:46:55 +02:00
Will Hunt
2374a3fd33 Prevent showing calling view when disconnected from Livekit. (#3491)
* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie
2025-09-16 13:16:11 +00:00
davidegirardi
5811794f31 Add drum/joke reaction (#3217)
* Add drum/joke reaction

* Add license information

* Update test snapshot

* update snapshot test

---------

Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Davide Girardi <davideg@element.io>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-16 11:46:25 +02:00
37 changed files with 690 additions and 232 deletions

View File

@@ -44,6 +44,8 @@ jobs:
run: |
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "TAG=latest" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
echo "TAG=rc" >> "$GITHUB_OUTPUT"
else
echo "TAG=other" >> "$GITHUB_OUTPUT"
fi
@@ -163,6 +165,8 @@ jobs:
run: |
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
else
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
fi

View File

@@ -59,6 +59,8 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {

View File

@@ -44,6 +44,10 @@ rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000

View File

@@ -44,7 +44,7 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.38.0",
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
@@ -54,7 +54,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.52.0",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
@@ -105,7 +105,7 @@
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.27.2",
"livekit-client": "^2.13.0",
"livekit-client": "^2.15.13",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",

View File

@@ -159,8 +159,8 @@ export const widgetTest = test.extend<MyFixtures>({
} = await registerUser(browser, userB);
// Invite the second user
await ewPage1.getByRole("button", { name: "Add room" }).click();
await ewPage1.getByText("New room").click();
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
@@ -184,9 +184,9 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the invite
await expect(
ewPage2.getByRole("treeitem", { name: "Welcome Room" }),
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),

View File

@@ -0,0 +1,60 @@
/*
Copyright 2025 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 { expect, test } from "@playwright/test";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode',
);
test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("Test user");
// If we do not call fastForward here, we end up with Date.now() returning an actual timestamp
// but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0
// and we will never get into probablyDisconnected state?
await page.clock.fastForward(10);
await page.getByTestId("home_go").click();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// The media tile for the local user should become visible
await new Promise((resolve) => setTimeout(resolve, 1500));
await expect(page.getByTestId("name_tag")).toContainText("Test user");
// Now disconnect from the internet
await page.route("https://synapse.m.localhost/**/*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.clock.fastForward(20000);
await expect(
page.getByRole("dialog", { name: "Reconnecting…" }),
).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus();
await expect(
page.getByRole("button", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
});

View File

@@ -49,7 +49,10 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
// Check the join indicator on the room list
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }),
brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("button", { name: "End call" }),
).toBeVisible();
// Join from the other side
@@ -59,26 +62,28 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible();
// Currently disabled due to recent Element Web is bypassing Lobby
// await expect(
// whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall"),
// ).toBeVisible();
//
// await whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall")
// .click();
await whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall")
.click();
// Currrenty disabled due to recent Element Web not indicating the number of participants
// await expect(
// whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
await expect(
whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
// await expect(
// brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
// Whistler leaves
await whistler.page.waitForTimeout(1000);

View File

@@ -35,6 +35,8 @@ Please see LICENSE in the repository root for full details.
.bg.animate[data-state="closed"] {
animation: fade-out 130ms;
opacity: 0;
pointer-events: none;
}
.overlay {

View File

@@ -46,11 +46,11 @@ interface Props {
*/
Icon?: ComponentType<SVGAttributes<SVGElement>>;
/**
* Whether the toast should be portaled into the root of the document (rather
* than rendered in-place within the component tree).
* Whether the toast should be modal, making it fill the screen (by portalling
* it into the root of the document) and trap focus until dismissed.
* @default true
*/
portal?: boolean;
modal?: boolean;
}
/**
@@ -62,7 +62,7 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
portal = true,
modal = true,
}) => {
const onOpenChange = useCallback(
(open: boolean) => {
@@ -103,8 +103,8 @@ export const Toast: FC<Props> = ({
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
{portal ? <DialogPortal>{content}</DialogPortal> : content}
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot>
);
};

View File

@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import * as PlatformMod from "../src/Platform";
import {
getRoomIdentifierFromUrl,
getUrlParams,
computeUrlParams,
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -103,16 +106,16 @@ describe("UrlParams", () => {
describe("preload", () => {
it("defaults to false", () => {
expect(getUrlParams().preload).toBe(false);
expect(computeUrlParams().preload).toBe(false);
});
it("ignored in SPA mode", () => {
expect(getUrlParams("?preload=true").preload).toBe(false);
expect(computeUrlParams("?preload=true").preload).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).preload,
).toBe(true);
@@ -121,19 +124,20 @@ describe("UrlParams", () => {
describe("returnToLobby", () => {
it("is false in SPA mode", () => {
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
});
it("defaults to false in widget mode", () => {
expect(
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo")
.returnToLobby,
computeUrlParams(
"?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(true);
@@ -142,12 +146,12 @@ describe("UrlParams", () => {
describe("userId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?userId=asd").userId).toBe(null);
expect(computeUrlParams("?userId=asd").userId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).userId,
).toBe("asd");
@@ -156,12 +160,12 @@ describe("UrlParams", () => {
describe("deviceId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null);
expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).deviceId,
).toBe("asd");
@@ -170,12 +174,12 @@ describe("UrlParams", () => {
describe("baseUrl", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null);
expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).baseUrl,
).toBe("asd");
@@ -185,28 +189,28 @@ describe("UrlParams", () => {
describe("viaServers", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).viaServers,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd");
expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
});
});
describe("homeserver", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).homeserver,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd");
expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
});
});
@@ -237,7 +241,7 @@ describe("UrlParams", () => {
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
sendNotificationType: platform === "desktop" ? "notification" : "ring",
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
@@ -252,24 +256,55 @@ describe("UrlParams", () => {
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
});
it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults);
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
});
it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
});
it("accepts start_call", () => {
expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
).toMatchObject(startNewCallDefaults("desktop"));
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
});
it("accepts start_call_dm mobile", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(startNewCallDefaults("android"));
});
it("accepts start_call_dm mobile and prioritizes overwritten params", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org&sendNotificationType=notification",
),
).toMatchObject({
...startNewCallDefaults("android"),
sendNotificationType: "notification",
});
});
it("accepts join_existing", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
@@ -278,31 +313,55 @@ describe("UrlParams", () => {
describe("skipLobby", () => {
it("defaults to false", () => {
expect(getUrlParams().skipLobby).toBe(false);
expect(computeUrlParams().skipLobby).toBe(false);
});
it("defaults to false if intent is start_call in SPA mode", () => {
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false);
expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
});
it("defaults to true if intent is start_call in widget mode", () => {
it("defaults to false if intent is start_call in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).skipLobby,
).toBe(true);
).toBe(false);
});
it("default to false if intent is join_existing", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
expect(computeUrlParams("?header=none&hideHeader=false").header).toBe(
"none",
);
});
});
describe("getUrlParams", () => {
it("uses cached values", () => {
const spy = vi.spyOn(logger, "info");
// call get once
const params = getUrlParams("?header=app_bar&hideHeader=true", "");
// call get twice
expect(getUrlParams("?header=app_bar&hideHeader=true", "")).toBe(params);
// expect compute to only be called once
// it will only log when it is computing the values
expect(spy).toHaveBeenCalledExactlyOnceWith(
"UrlParams: final set of url params\n",
"intent:",
"unknown",
"\nproperties:",
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
});
});
});

View File

@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
@@ -26,7 +29,9 @@ export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
@@ -227,6 +232,16 @@ export interface UrlConfiguration {
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
callIntent?: RTCCallIntent;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
@@ -234,7 +249,10 @@ export interface UrlConfiguration {
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams extends UrlProperties, UrlConfiguration {}
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -310,8 +328,14 @@ class ParamParser {
}
}
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/**
* Gets the app parameters for the current URL.
* Gets the url params and loads them from a cache if already computed.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
@@ -320,6 +344,26 @@ export const getUrlParams = (
search = window.location.search,
hash = window.location.hash,
): UrlParams => {
if (
urlParamCache.search === search &&
urlParamCache.hash === hash &&
urlParamCache.params
) {
return urlParamCache.params;
}
const params = computeUrlParams(search, hash);
urlParamCache = { search, hash, params };
return params;
};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
*/
export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -343,8 +387,7 @@ export const getUrlParams = (
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration;
const inAppDefault = {
let intentPreset: UrlConfiguration = {
confineToRoom: true,
appPrompt: false,
preload: false,
@@ -356,37 +399,38 @@ export const getUrlParams = (
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
skipLobby: false,
};
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: true,
};
intentPreset.skipLobby = true;
intentPreset.sendNotificationType = "ring";
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.waitForCallPickup = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = true;
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
@@ -408,6 +452,29 @@ export const getUrlParams = (
};
}
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -460,10 +527,25 @@ export const getUrlParams = (
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«rb5»"
aria-labelledby="«rbb»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"

View File

@@ -9,6 +9,7 @@ import {
ConnectionState,
type E2EEManagerOptions,
ExternalE2EEKeyProvider,
type LocalTrackPublication,
LocalVideoTrack,
Room,
type RoomOptions,
@@ -181,6 +182,33 @@ export function useLivekit(
sfuConfig,
);
// Log errors when local participant has issues publishing a track.
useEffect(() => {
const localTrackUnpublishedFn = (
publication: LocalTrackPublication,
): void => {
logger.info(
"Local track unpublished",
publication.trackName,
publication.trackInfo,
);
};
const mediaDevicesErrorFn = (error: Error): void => {
logger.warn("Media devices error when publishing a track", error);
};
room.localParticipant.on("localTrackUnpublished", localTrackUnpublishedFn);
room.localParticipant.on("mediaDevicesError", mediaDevicesErrorFn);
return (): void => {
room.localParticipant.off(
"localTrackUnpublished",
localTrackUnpublishedFn,
);
room.localParticipant.off("mediaDevicesError", mediaDevicesErrorFn);
};
}, [room.localParticipant]);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the

View File

@@ -22,11 +22,13 @@ export function RaisedHandIndicator({
miniature,
showTimer,
onClick,
tabIndex,
}: {
raisedHandTime?: Date;
miniature?: boolean;
showTimer?: boolean;
onClick?: () => void;
tabIndex?: number;
}): ReactNode {
const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState("");
@@ -94,6 +96,7 @@ export function RaisedHandIndicator({
background: "none",
}}
onClick={clickCallback}
tabIndex={tabIndex}
>
{content}
</button>

View File

@@ -27,6 +27,8 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -191,6 +193,15 @@ export const ReactionSet: ReactionOption[] = [
mp3: waveSoundMp3,
},
},
{
emoji: "🥁",
name: "drum",
alias: ["joke"],
sound: {
ogg: baduntssSoundOgg,
mp3: baduntssSoundMp3,
},
},
];
export interface RaisedHandInfo {

View File

@@ -34,7 +34,6 @@
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}

View File

@@ -20,7 +20,7 @@ interface Props {
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<div className={styles.overlay} data-show={show} aria-hidden={!show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
</BigIcon>

View File

@@ -128,6 +128,16 @@ export const GroupCallView: FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update our member event when our mute state changes.
useEffect(() => {
if (!isJoined) {
return;
}
void rtcSession.updateCallIntent(
muteStates.video.enabled ? "video" : "audio",
);
}, [rtcSession, isJoined, muteStates.video.enabled]);
useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted");
return (): void => {

View File

@@ -54,7 +54,6 @@ import { type HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css";
import overlayStyles from "../Overlay.module.css";
import { GridTile } from "../tile/GridTile";
import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -119,6 +118,7 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { Avatar, Size as AvatarSize } from "../Avatar";
import waitingStyles from "./WaitingForJoin.module.css";
import { prefetchSounds } from "../soundUtils";
@@ -641,6 +641,38 @@ export const InCallView: FC<InCallViewProps> = ({
}
}
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
// We need to use a non-modal toast to avoid trapping focus within the toast.
// However, a non-modal toast will not render any background overlay on its
// own, so we must render one manually.
const reconnectingToast = (
<>
<div
className={classNames(overlayStyles.bg, overlayStyles.animate)}
data-state={reconnecting ? "open" : "closed"}
/>
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
modal={false}
>
{t("common.reconnecting")}
</Toast>
</>
);
const earpieceOverlay = (
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
);
// If the reconnecting toast or earpiece overlay obscures the media tiles, we
// need to remove them from the accessibility tree and block focus.
const contentObscured = reconnecting || earpieceMode;
const Tile = useMemo(
() =>
function Tile({
@@ -670,6 +702,7 @@ export const InCallView: FC<InCallViewProps> = ({
className={classNames(className, styles.tile)}
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
focusable={!contentObscured}
/>
) : (
<SpotlightTile
@@ -680,12 +713,13 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
/>
);
},
[vm, openProfile],
[vm, openProfile, contentObscured],
);
const layouts = useMemo(() => {
@@ -714,6 +748,8 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
);
}
@@ -731,6 +767,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout}
Layout={layers.fixed}
Tile={Tile}
aria-hidden={contentObscured}
/>
);
const scrollingGrid = (
@@ -740,6 +777,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout}
Layout={layers.scrolling}
Tile={Tile}
aria-hidden={contentObscured}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
@@ -869,9 +907,6 @@ export const InCallView: FC<InCallViewProps> = ({
</div>
);
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
return (
<div
className={styles.inRoom}
@@ -899,17 +934,8 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
portal={false}
>
{t("common.reconnecting")}
</Toast>
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
{reconnectingToast}
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{waitingOverlay}
{footer}

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
@@ -26,7 +27,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
vi.mock("@livekit/components-core");
interface TestComponentProps {
@@ -110,9 +110,10 @@ function mockMediaDevices(
return new MediaDevices(scope);
}
describe("useMuteStates", () => {
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
afterEach(() => {
vi.clearAllMocks();
vi.stubEnv("VITE_PACKAGE", "full");
});
afterAll(() => {
@@ -256,3 +257,67 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});
describe("useMuteStates in VITE_PACKAGE='embedded' (widget) mode", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("uses defaults from config", () => {
mockConfig({
media_devices: {
enable_audio: false,
enable_video: false,
},
});
render(
<MemoryRouter>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("skipLobby does not mute inputs", () => {
mockConfig();
render(
<MemoryRouter
initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
it("url params win over config", () => {
// The config sets audio and video to disabled
mockConfig({ media_devices: { enable_audio: false, enable_video: false } });
render(
<MemoryRouter
initialEntries={[
// The Intent sets both audio and video enabled to true via the url param configuration
"/room/?intent=start_call_dm&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
// At the end we expect the url param to take precedence, resulting in true
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});

View File

@@ -81,11 +81,15 @@ function useMuteState(
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
const { skipLobby } = useUrlParams();
const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } =
useUrlParams();
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
const audio = useMuteState(
devices.audioInput,
() =>
(defaultAudioEnabled ?? Config.get().media_devices.enable_audio) &&
allowJoinUnmuted(skipLobby, isJoined),
);
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
@@ -97,7 +101,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
() =>
(defaultVideoEnabled ?? Config.get().media_devices.enable_video) &&
allowJoinUnmuted(skipLobby, isJoined),
isEarpiece, // Force video to be unavailable if using earpiece
);
@@ -164,3 +170,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
return useMemo(() => ({ audio, video }), [audio, video]);
}
function allowJoinUnmuted(skipLobby: boolean, isJoined: boolean): boolean {
return (
(!skipLobby && !isJoined) || import.meta.env.VITE_PACKAGE === "embedded"
);
}

View File

@@ -111,6 +111,11 @@ exports[`InCallView > rendering > renders 1`] = `
<div />
</div>
<div
class="bg animate"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay"
data-show="false"
>

View File

@@ -120,11 +120,13 @@ export async function enterRTCSession(
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
rtcSession.joinRoomSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(),
{
notificationType: getUrlParams().sendNotificationType,
notificationType,
callIntent,
useNewMembershipManager,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {

View File

@@ -23,3 +23,4 @@ The following sound effects have been originally created by Element.
- `end_call`
- `reactions/rock`
- `reactions/wave`
- `reactions/baduntss`

Binary file not shown.

Binary file not shown.

View File

@@ -266,7 +266,7 @@ const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
interface CallViewModelInputs {
remoteParticipants$: Behavior<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
connectionState$: Observable<ECConnectionState>;
livekitConnectionState$: Observable<ECConnectionState>;
speaking: Map<Participant, Observable<boolean>>;
mediaDevices: MediaDevices;
initialSyncState: SyncState;
@@ -276,7 +276,7 @@ function withCallViewModel(
{
remoteParticipants$ = constant([]),
rtcMembers$ = constant([localRtcMember]),
connectionState$ = of(ConnectionState.Connected),
livekitConnectionState$: connectionState$ = of(ConnectionState.Connected),
speaking = new Map(),
mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing,
@@ -384,7 +384,7 @@ test("participants are retained during a focus switch", () => {
b: [],
}),
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
connectionState$: behavior(connectionInputMarbles, {
livekitConnectionState$: behavior(connectionInputMarbles, {
c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus,
}),
@@ -1251,6 +1251,41 @@ describe("waitForCallPickup$", () => {
});
});
test("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => {
withTestScheduler(({ schedule, expectObservable, behavior }) => {
withCallViewModel(
{
livekitConnectionState$: behavior("d 9ms c", {
d: ConnectionState.Disconnected,
c: ConnectionState.Connected,
}),
},
(vm, rtcSession) => {
// Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits)
schedule("n", {
n: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$notif1", 30),
mockLegacyRingEvent,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("ringing -> success if someone joins before timeout", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
@@ -1305,7 +1340,7 @@ describe("waitForCallPickup$", () => {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
connectionState$,
livekitConnectionState$: connectionState$,
},
(vm, rtcSession) => {
// Notify at 5ms so we enter ringing, then get disconnected 5ms later

View File

@@ -880,60 +880,68 @@ export class CallViewModel extends ViewModel {
? this.allOthersLeft$
: NEVER;
private readonly didSendCallNotification$ = fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<
Parameters<
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
>
>;
/**
* Whenever the RTC session tells us that it intends to ring the remote
* participant's devices, this emits an Observable tracking the current state of
* that ringing process.
*/
private readonly ring$: Observable<
Observable<"ringing" | "timeout" | "decline">
> = (
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<
Parameters<
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
>
>
).pipe(
filter(
([notificationEvent]) => notificationEvent.notification_type === "ring",
),
map(([notificationEvent]) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
NEVER,
).pipe(
takeUntil(
(
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(
filter(
([event]) =>
event.getType() === EventType.RTCDecline &&
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() !== this.userId,
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
// A behavior will emit the latest observable with the running timer to new subscribers.
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
// `ring$` would not be a behavior.
private readonly ring$: Behavior<
Observable<"ringing" | "timeout" | "decline"> | Observable<never>
> = this.scope.behavior(
this.didSendCallNotification$.pipe(
filter(
([notificationEvent]) => notificationEvent.notification_type === "ring",
),
map(([notificationEvent]) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
NEVER,
).pipe(
takeUntil(
(
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(
filter(
([event]) =>
event.getType() === EventType.RTCDecline &&
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== this.userId,
),
),
),
),
endWith("decline" as const),
);
}),
endWith("decline" as const),
);
}),
),
EMPTY,
);
/**
@@ -972,6 +980,8 @@ export class CallViewModel extends ViewModel {
return of("success" as const);
}
// Show the ringing state of the most recent ringing attempt.
// ring$ is a behavior so it will emit the latest observable which very well might already have a running timer.
// this is important in case livekitConnectionState$ after didSendCallNotification$ has already emitted.
return this.ring$.pipe(switchAll());
}),
// The state starts as 'unknown' because we don't know if the RTC

View File

@@ -64,6 +64,7 @@ test("GridTile is accessible", async () => {
targetWidth={300}
targetHeight={200}
showSpeakingIndicators
focusable={true}
/>
</ReactionsSenderProvider>,
);

View File

@@ -60,6 +60,7 @@ interface TileProps {
targetHeight: number;
displayName: string;
showSpeakingIndicators: boolean;
focusable: boolean;
}
interface UserMediaTileProps extends TileProps {
@@ -81,6 +82,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
menuEnd,
className,
displayName,
focusable,
...props
}) => {
const { toggleRaisedHand } = useReactionsSender();
@@ -162,6 +164,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
/>
}
displayName={displayName}
focusable={focusable}
primaryButton={
primaryButton ?? (
<Menu
@@ -169,7 +172,10 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
@@ -208,6 +214,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
ref,
vm,
onOpenProfile,
focusable,
...props
}) => {
const { t } = useTranslation();
@@ -236,6 +243,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
className={styles.switchCamera}
aria-label={t("switch_camera")}
onClick={switchCamera}
tabIndex={focusable ? undefined : -1}
>
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
</button>
@@ -258,6 +266,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
/>
)
}
focusable={focusable}
{...props}
/>
);
@@ -337,6 +346,7 @@ interface GridTileProps {
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
focusable: boolean;
}
export const GridTile: FC<GridTileProps> = ({

View File

@@ -47,6 +47,7 @@ describe("MediaView", () => {
video: trackReference,
member: undefined,
localParticipant: false,
focusable: true,
};
test("is accessible", async () => {

View File

@@ -38,6 +38,7 @@ interface Props extends ComponentProps<typeof animated.div> {
encryptionStatus: EncryptionStatus;
nameTagLeadingIcon?: ReactNode;
displayName: string;
focusable: boolean;
primaryButton?: ReactNode;
raisedHandTime?: Date;
currentReaction?: ReactionOption;
@@ -61,6 +62,7 @@ export const MediaView: FC<Props> = ({
unencryptedWarning,
nameTagLeadingIcon,
displayName,
focusable,
primaryButton,
encryptionStatus,
raisedHandTime,
@@ -114,6 +116,7 @@ export const MediaView: FC<Props> = ({
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
tabIndex={focusable ? undefined : -1}
/>
{currentReaction && (
<ReactionIndicator
@@ -164,6 +167,7 @@ export const MediaView: FC<Props> = ({
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
>
<ErrorSolidIcon
width={20}

View File

@@ -59,6 +59,7 @@ test("SpotlightTile is accessible", async () => {
expanded={false}
onToggleExpanded={toggleExpanded}
showIndicators
focusable={true}
/>,
);

View File

@@ -59,6 +59,7 @@ interface SpotlightItemBaseProps {
unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
displayName: string;
focusable: boolean;
"aria-hidden"?: boolean;
localParticipant: boolean;
}
@@ -112,6 +113,7 @@ interface SpotlightItemProps {
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
@@ -125,6 +127,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
vm,
targetWidth,
targetHeight,
focusable,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
@@ -163,6 +166,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
member: vm.member,
unencryptedWarning,
displayName,
focusable,
encryptionStatus,
"aria-hidden": ariaHidden,
localParticipant: vm.local,
@@ -185,6 +189,7 @@ interface Props {
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
@@ -197,6 +202,7 @@ export const SpotlightTile: FC<Props> = ({
targetWidth,
targetHeight,
showIndicators,
focusable = true,
className,
style,
}) => {
@@ -293,6 +299,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
tabIndex={focusable ? undefined : -1}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
@@ -304,6 +311,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
focusable={focusable}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
@@ -319,6 +327,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
tabIndex={focusable ? undefined : -1}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
@@ -330,6 +339,7 @@ export const SpotlightTile: FC<Props> = ({
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
tabIndex={focusable ? undefined : -1}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
@@ -341,6 +351,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
tabIndex={focusable ? undefined : -1}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>

View File

@@ -360,6 +360,8 @@ export class MockRTCSession extends TypedEventEmitter<
return this;
}
public updateCallIntent = vitest.fn();
private _membershipStatus = Status.Connected;
public get membershipStatus(): Status {
return this._membershipStatus;

View File

@@ -26,9 +26,12 @@ export default defineConfig((env) =>
output: "./config.json",
data: {
matrix_rtc_session: {
wait_for_key_rotation_ms: 5000,
delayed_leave_event_delay_ms: 90000,
delayed_leave_event_restart_local_timeout_ms: 10000,
delayed_leave_event_restart_ms: 4000,
delayed_leave_event_delay_ms: 18000,
membership_event_expiry_ms: 7200000,
network_error_retry_ms: 100,
wait_for_key_rotation_ms: 5000,
},
},
},

122
yarn.lock
View File

@@ -2772,12 +2772,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.39.3, @livekit/protocol@npm:^1.38.0":
version: 1.39.3
resolution: "@livekit/protocol@npm:1.39.3"
"@livekit/protocol@npm:1.42.2, @livekit/protocol@npm:^1.42.2":
version: 1.42.2
resolution: "@livekit/protocol@npm:1.42.2"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/192ce7283db4505232aa487e71d08670783a1045a8935926f95ff4b9d3d310db706c2ad4af2ebdb0bd735c2e9042c1ee3b1f62e44a9d01bd41fd58cf8dc272b9
checksum: 10c0/73e77e0975d4d8ca0baa23ad03cb9ac3a844e8d89e9cefbd17343131a774c7aeb4d190fd0397152fcf5a396d921af3eb83e83e660a4aa12215d9ac4e52bf037a
languageName: node
linkType: hard
@@ -2793,10 +2793,10 @@ __metadata:
languageName: node
linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.1.0":
version: 15.1.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.1.0"
checksum: 10c0/19edc6d0045ff49fad8d77b6e561cee994f7513f8c18a7176ae2d3f0116c1a91980e02d10300b09c2b72dea4da4a8c3392f2bf1752057f2d6b53030a056d76d8
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0":
version: 15.3.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0"
checksum: 10c0/45628f36b7b0e54a8777ae67a7233dbdf3e3cf14e0d95d21f62f89a7ea7e3f907232f1eb7b1262193b1e227759fad47af829dcccc103ded89011f13c66f01d76
languageName: node
linkType: hard
@@ -3371,14 +3371,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.52.0":
version: 1.54.1
resolution: "@playwright/test@npm:1.54.1"
"@playwright/test@npm:^1.56.1":
version: 1.56.1
resolution: "@playwright/test@npm:1.56.1"
dependencies:
playwright: "npm:1.54.1"
playwright: "npm:1.56.1"
bin:
playwright: cli.js
checksum: 10c0/1b414356bc1049927d7b9efc14d5b3bf000ef6483313926bb795b4f27fe3707e8e0acf0db59063a452bb4f7e34559758d17640401b6f3e2f5290f299a8d8d02f
checksum: 10c0/2b5b0e1f2e6a18f6e5ce6897c7440ca78f64e0b004834e9808e93ad2b78b96366b562ae4366602669cf8ad793a43d85481b58541e74be71e905e732d833dd691
languageName: node
linkType: hard
@@ -5290,13 +5290,6 @@ __metadata:
languageName: node
linkType: hard
"@types/retry@npm:0.12.0":
version: 0.12.0
resolution: "@types/retry@npm:0.12.0"
checksum: 10c0/7c5c9086369826f569b83a4683661557cab1361bac0897a1cefa1a915ff739acd10ca0d62b01071046fe3f5a3f7f2aec80785fe283b75602dc6726781ea3e328
languageName: node
linkType: hard
"@types/sdp-transform@npm:^2.4.5":
version: 2.4.10
resolution: "@types/sdp-transform@npm:2.4.10"
@@ -7480,7 +7473,7 @@ __metadata:
"@formatjs/intl-segmenter": "npm:^11.7.3"
"@livekit/components-core": "npm:^0.12.0"
"@livekit/components-react": "npm:^2.0.0"
"@livekit/protocol": "npm:^1.38.0"
"@livekit/protocol": "npm:^1.42.2"
"@livekit/track-processors": "npm:^0.5.5"
"@mediapipe/tasks-vision": "npm:^0.10.18"
"@opentelemetry/api": "npm:^1.4.0"
@@ -7490,7 +7483,7 @@ __metadata:
"@opentelemetry/sdk-trace-base": "npm:^2.0.0"
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
"@opentelemetry/semantic-conventions": "npm:^1.25.1"
"@playwright/test": "npm:^1.52.0"
"@playwright/test": "npm:^1.56.1"
"@radix-ui/react-dialog": "npm:^1.0.4"
"@radix-ui/react-slider": "npm:^1.1.2"
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
@@ -7541,7 +7534,7 @@ __metadata:
i18next-parser: "npm:^9.1.0"
jsdom: "npm:^26.0.0"
knip: "npm:^5.27.2"
livekit-client: "npm:^2.13.0"
livekit-client: "npm:^2.15.13"
lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1"
matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop"
@@ -9528,6 +9521,13 @@ __metadata:
languageName: node
linkType: hard
"is-network-error@npm:^1.1.0":
version: 1.3.0
resolution: "is-network-error@npm:1.3.0"
checksum: 10c0/3e85a69e957988db66d5af5412efdd531a5a63e150d1bdd5647cfd4dc54fd89b1dbdd472621f8915233c3176ba1e6922afa8a51a9e363ba4693edf96a294f898
languageName: node
linkType: hard
"is-number-object@npm:^1.1.1":
version: 1.1.1
resolution: "is-number-object@npm:1.1.1"
@@ -9790,6 +9790,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^6.1.0":
version: 6.1.0
resolution: "jose@npm:6.1.0"
checksum: 10c0/f4518579e907317e144facd15c7627acd06097bbea17735097437217498aa419564c039dd4020f6af5f2d024a7cee6b7be4648ccbbdc238aedb80a47c061217d
languageName: node
linkType: hard
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -10064,13 +10071,14 @@ __metadata:
languageName: node
linkType: hard
"livekit-client@npm:^2.13.0":
version: 2.15.6
resolution: "livekit-client@npm:2.15.6"
"livekit-client@npm:^2.15.13":
version: 2.15.13
resolution: "livekit-client@npm:2.15.13"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.39.3"
"@livekit/protocol": "npm:1.42.2"
events: "npm:^3.3.0"
jose: "npm:^6.1.0"
loglevel: "npm:^1.9.2"
sdp-transform: "npm:^2.15.0"
ts-debounce: "npm:^4.0.0"
@@ -10079,7 +10087,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192
checksum: 10c0/5a061df9000461a6d40ef8aa1e72e8aedc640181cc57fe6f2c48c5c7f90ce96a735b125aede377fc43f4692a685e098f17eeae0f42c5b2fed473305867bf2789
languageName: node
linkType: hard
@@ -10298,11 +10306,11 @@ __metadata:
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
version: 37.13.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2f1d654f14be8dd03896e9e76f12017b6f9eec1c"
version: 38.3.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=41d70d0b5d3f0eba92686f8089cb329d875b26b5"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0"
another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4"
@@ -10311,11 +10319,11 @@ __metadata:
matrix-events-sdk: "npm:0.0.1"
matrix-widget-api: "npm:^1.10.0"
oidc-client-ts: "npm:^3.0.1"
p-retry: "npm:4"
p-retry: "npm:7"
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805
uuid: "npm:13"
checksum: 10c0/b48528fec573f3e14d1297f360a56d52d7f313da0d4cf82ab51e4c29798b86995b8a6bd72409779746e7bcf02949bc2788bffa9aba276bfb1a76dbcbe89900a0
languageName: node
linkType: hard
@@ -10922,13 +10930,12 @@ __metadata:
languageName: node
linkType: hard
"p-retry@npm:4":
version: 4.6.2
resolution: "p-retry@npm:4.6.2"
"p-retry@npm:7":
version: 7.0.0
resolution: "p-retry@npm:7.0.0"
dependencies:
"@types/retry": "npm:0.12.0"
retry: "npm:^0.13.1"
checksum: 10c0/d58512f120f1590cfedb4c2e0c42cb3fa66f3cea8a4646632fcb834c56055bb7a6f138aa57b20cc236fb207c9d694e362e0b5c2b14d9b062f67e8925580c73b0
is-network-error: "npm:^1.1.0"
checksum: 10c0/3c090ac72bbe00fd2f062ee6178c44f7302f298936ab2290a458575e73650e7834b556beb2b09fa9fbebedab2ec3358cb474c09710cf828972b670c3c0cb89e4
languageName: node
linkType: hard
@@ -11123,27 +11130,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.54.1":
version: 1.54.1
resolution: "playwright-core@npm:1.54.1"
"playwright-core@npm:1.56.1":
version: 1.56.1
resolution: "playwright-core@npm:1.56.1"
bin:
playwright-core: cli.js
checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
languageName: node
linkType: hard
"playwright@npm:1.54.1":
version: 1.54.1
resolution: "playwright@npm:1.54.1"
"playwright@npm:1.56.1":
version: 1.56.1
resolution: "playwright@npm:1.56.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.54.1"
playwright-core: "npm:1.56.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
languageName: node
linkType: hard
@@ -12230,13 +12237,6 @@ __metadata:
languageName: node
linkType: hard
"retry@npm:^0.13.1":
version: 0.13.1
resolution: "retry@npm:0.13.1"
checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772
languageName: node
linkType: hard
"reusify@npm:^1.0.4":
version: 1.1.0
resolution: "reusify@npm:1.1.0"
@@ -13766,12 +13766,12 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:11":
version: 11.0.5
resolution: "uuid@npm:11.0.5"
"uuid@npm:13":
version: 13.0.0
resolution: "uuid@npm:13.0.0"
bin:
uuid: dist/esm/bin/uuid
checksum: 10c0/6f59f0c605e02c14515401084ca124b9cb462b4dcac866916a49862bcf831874508a308588c23a7718269226ad11a92da29b39d761ad2b86e736623e3a33b6e7
uuid: dist-node/bin/uuid
checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67
languageName: node
linkType: hard