Compare commits

...

34 Commits

Author SHA1 Message Date
Half-Shot
30a130ac70 fix whoopsie 2025-09-16 14:10:27 +01:00
Half-Shot
82392bf582 Expose livekitConnectionState directly 2025-09-16 12:51:57 +01:00
Half-Shot
f072fd2f9c Add tests 2025-09-16 10:46:29 +01:00
Half-Shot
c00298f4a5 Update signature 2025-09-16 10:23:06 +01:00
Half-Shot
0721d48c20 Use "unknown" 2025-09-16 10:22:53 +01:00
Half-Shot
b16ba52e13 Refactor disconnection handling 2025-09-16 10:20:06 +01:00
Will Hunt
e201258af3 Add sounds for ringing (#3490)
* add wait for pickup overlay

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

* refactor and leave logic

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

* recursive play sound logic

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

* review

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

* text color

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

* overlay styling and interval fixes

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

* fix permissions and styling

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

* fix always getting pickup sound

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

* Add sound effects for declined,timeout and ringtone

* better ringtone

* Integrate sounds

* Ensure leave sound does not play

* Remove unused blocked sound

* fix test

* Improve tests

* Loop ring sound inside Audio context for better perf.

* lint

* better ringtone

* Update to delay ringtone logic.

* lint + fix test

* Tidy up ring sync and add comments.

* lint

* Refactor onLeave to take a sound so we don't need to repeat the sound

* fix import

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
2025-09-15 15:41:15 +01:00
Timo
76465d0e63 Add "wait for pickup" overlay with sound. Leave on decline/timeout (#3489)
* add wait for pickup overlay

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

* refactor and leave logic

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

* recursive play sound logic

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

* review

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

* text color

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

* overlay styling and interval fixes

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

* fix permissions and styling

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

* fix always getting pickup sound

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-15 09:58:16 +02:00
Robin
a2cf4c8139 Merge pull request #3479 from robintown/fix-reconnect
Fix the reconnect button
2025-09-12 15:05:47 +02:00
Will Hunt
65d358df58 Do not use preload mode by default in embedded mode (#3488)
* Set preload=false by default for inApp

* Pull through types for params so it's easy to document

* Cleanup boolean logic

* change test
2025-09-11 14:57:26 +01:00
Robin
32cb8541f4 Actually fix the test flake 2025-09-10 16:42:09 +02:00
Robin
62cfe09c7b Merge pull request #3482 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v6.3.6 [SECURITY]
2025-09-10 12:37:16 +02:00
Robin
8e32deaba5 Upgrade to Vite v7
To remove all the duplicate Rollup dependencies.
2025-09-10 12:31:03 +02:00
renovate[bot]
8311b1619c Update dependency vite to v6.3.6 [SECURITY] 2025-09-10 02:28:00 +00:00
Timo
fc449bcfbf Merge pull request #3467 from element-hq/toger5/call-pickup-state-decline-event
View model for decline logic
2025-09-09 15:23:39 +02:00
Robin
34a8977dd1 Increase timeout to hopefully avoid test flakes 2025-09-08 15:24:46 +02:00
Robin
db04cbfbfc Fix type error 2025-09-08 14:58:46 +02:00
Robin
c8d3d58940 Merge pull request #3400 from element-hq/robin/reactions-small
Prefer showing the reaction button at small screen widths
2025-09-08 14:54:51 +02:00
Robin
d85cf5f929 Fix the reconnect button
After a membership manager error, clicking the 'reconnect' button did nothing. This is because we were forgetting to clear the external error state, causing it to transition directly back to the same error state.
2025-09-08 14:21:38 +02:00
Robin
1e32b355ce Ignore decline events from the local user 2025-09-05 21:22:32 +02:00
Robin
1193a22658 Fix tests 2025-09-05 14:49:37 +02:00
Robin
2541f810fa Ensure that non-ringing notifications lead to a null pickup state 2025-09-05 14:36:27 +02:00
Robin
7961bb35be Merge pull request #3466 from element-hq/toger5/waitForNotificationAnswer
Add dialing/ringing state to CallViewModel (`callPickupState$`)
2025-09-05 14:11:37 +02:00
Andrew Ferrazzutti
075a4cbe0d Merge pull request #3475 from element-hq/af/docker-compose-pull-policy-always
docker compose: always pull latest image
2025-09-04 07:47:45 -04:00
Mohammed Tayeh
cd845b69d2 Add Caddy configuration example and fix ConfigOptions.ts link (#3476)
Signed-off-by: Mohammed Tayeh <m.tayeh94@gmail.com>
2025-09-04 10:19:37 +02:00
Andrew Ferrazzutti
779f792cbf docker compose: always pull latest image
Otherwise, there is a risk of using outdated images for some services.
2025-09-03 15:39:27 -04:00
Robin
e9c43856d0 Merge branch 'toger5/waitForNotificationAnswer' into toger5/call-pickup-state-decline-event 2025-09-03 17:59:16 +02:00
Timo K
7724cbf9be fix tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:21:27 +02:00
Timo K
fe65c1f4da fix decline event type
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:07:52 +02:00
Timo K
a91c71a8e4 types
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Timo K
e30142a43b Add decline logic and tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Robin
30024877fc Test that the reaction button is visible at small screen widths 2025-08-14 17:00:56 +02:00
Robin
aa0f3a1126 Merge branch 'robin/switch-camera-tile' into robin/reactions-small 2025-08-14 16:39:51 +02:00
Robin
735d63d393 Prefer showing the reaction button at small screen widths
Rather than the 'share screen' button. Small screens are most likely to be mobile devices which wouldn't have the ability to share their screen, anyways.
2025-07-10 16:07:14 -04:00
32 changed files with 905 additions and 327 deletions

View File

@@ -26,6 +26,7 @@ services:
livekit:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
@@ -45,6 +46,7 @@ services:
redis:
image: redis:6-alpine
pull_policy: always
command: redis-server /etc/redis.conf
ports:
# HOST_PORT:CONTAINER_PORT
@@ -57,6 +59,7 @@ services:
synapse:
hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
@@ -87,6 +90,7 @@ services:
# see backend/dev_tls_setup for how to generate the tls certs
hostname: synapse.m.localhost
image: nginx:latest
pull_policy: always
volumes:
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z

View File

@@ -126,6 +126,32 @@ server {
}
```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
# Default route for livekit
handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
@@ -214,7 +240,7 @@ server {
There are currently two different config files. `.env` holds variables that are
used at build time, while `public/config.json` holds variables that are used at
runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](src/config/ConfigOptions.ts).
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
> [!CAUTION]
> Please note configuring MatrixRTC backend via `config.json` of

View File

@@ -129,7 +129,7 @@
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"vaul": "^1.0.0",
"vite": "^6.0.0",
"vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",

View File

@@ -40,6 +40,11 @@ test("Start a new call then leave and show the feedback screen", async ({
// The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// Resize the window to resemble a small mobile phone
await page.setViewportSize({ width: 350, height: 660 });
// We should still be able to send reactions at this screen size
await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible();
// leave the call
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(

View File

@@ -228,7 +228,7 @@ describe("UrlParams", () => {
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -242,7 +242,7 @@ describe("UrlParams", () => {
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,

View File

@@ -347,7 +347,7 @@ export const getUrlParams = (
const inAppDefault = {
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,

View File

@@ -54,6 +54,8 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});
@@ -105,6 +107,20 @@ test("plays a sound when a user leaves", () => {
expect(playSound).toBeCalledWith("left");
});
test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).not.toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => {
const mockRtcMemberships: CallMembership[] = [localRtcMember];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {

View File

@@ -16,6 +16,10 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
@@ -37,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg,
},
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
});
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({
vm,
muted,

View File

@@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
async (action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
return Promise.resolve();
},
[setFailState],
);

View File

@@ -36,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
export type RecoveryActionHandler = (
action: CallErrorRecoveryAction,
) => Promise<void>;
interface ErrorPageProps {
error: ElementCallError;
@@ -71,7 +73,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"),
onClick: () => void recoveryActionHandler("reconnect"),
});
}
@@ -131,9 +133,9 @@ export const GroupCallErrorBoundary = ({
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
await recoveryActionHandler(action);
resetError();
recoveryActionHandler(action);
}}
/>
);

View File

@@ -12,10 +12,14 @@ import {
onTestFinished,
test,
vi,
vitest,
} from "vitest";
import { render, waitFor, screen } from "@testing-library/react";
import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -94,13 +98,15 @@ beforeEach(() => {
playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vi.fn(),
soundDuration: {},
});
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
({ onLeave }) => {
return (
<div>
<button onClick={() => onLeave()}>Leave</button>
<button onClick={() => onLeave("user")}>Leave</button>
</div>
);
},
@@ -206,6 +212,8 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
const { getByText, rtcSession } = createGroupCallView(
@@ -258,3 +266,19 @@ test("GroupCallView shows errors that occur during joining", async () => {
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
);
// XXX: Wrapping the following click in act() shouldn't be necessary (the
// async state update should be processed automatically by the waitFor call),
// and yet here we are.
await act(async () =>
user.click(screen.getByRole("button", { name: "Reconnect" })),
);
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});

View File

@@ -50,10 +50,13 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
@@ -83,8 +86,8 @@ interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
confineToRoom: boolean;
preload: boolean;
skipLobby: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
header: HeaderStyle;
rtcSession: MatrixRTCSession;
isJoined: boolean;
@@ -276,34 +279,28 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
if (widget) {
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
if (widget && preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
logger.error("Error joining RTC session on preload", e);
});
}
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
void enterRTCSessionOrError(rtcSession);
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession);
})().catch((e) => {
logger.error("Error joining RTC session immediately", e);
});
}
}
}, [
@@ -323,8 +320,11 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate();
const onLeave = useCallback(
(cause: "user" | "error" = "user"): void => {
const audioPromise = leaveSoundContext.current?.playSound("left");
(
cause: "user" | "error" = "user",
playSound: CallEventSounds = "left",
): void => {
const audioPromise = leaveSoundContext.current?.playSound(playSound);
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
@@ -494,6 +494,7 @@ export const GroupCallView: FC<Props> = ({
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null;
} else {
body = lobbyView;
@@ -502,10 +503,11 @@ export const GroupCallView: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={(action) => {
recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(rtcSession).catch((e) => {
await enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}

View File

@@ -108,13 +108,6 @@ Please see LICENSE in the repository root for full details.
}
@media (max-width: 370px) {
.raiseHand {
display: none;
}
}
@media (max-width: 340px) {
.invite,
.shareScreen {
display: none;
}
@@ -126,6 +119,13 @@ Please see LICENSE in the repository root for full details.
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
display: none;
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x);

View File

@@ -17,7 +17,7 @@ import { act, render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { ConnectionState, type LocalParticipant } from "livekit-client";
import { type LocalParticipant } from "livekit-client";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -180,7 +180,6 @@ function createInCallView(): RenderResult & {
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext>

View File

@@ -25,7 +25,11 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useSubscription } from "observable-hooks";
import {
useObservable,
useObservableEagerState,
useSubscription,
} from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
@@ -50,6 +54,7 @@ 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";
@@ -62,7 +67,6 @@ import { type MuteStates } from "./MuteStates";
import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import { type ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import {
CallViewModel,
@@ -94,7 +98,10 @@ import {
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
CallEventAudioRenderer,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
@@ -112,6 +119,12 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
import { Avatar, Size as AvatarSize } from "../Avatar";
import waitingStyles from "./WaitingForJoin.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -158,7 +171,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
@@ -171,6 +185,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup:
waitForCallPickup && sendNotificationType === "ring",
},
connStateObservable$,
reactionsReader.raisedHands$,
@@ -190,6 +206,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -197,12 +215,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (
<RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
</ReactionsSenderProvider>
</RoomContext>
);
@@ -217,10 +230,9 @@ export interface InCallViewProps {
livekitRoom: LivekitRoom;
muteStates: MuteStates;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
}
@@ -234,7 +246,6 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates,
onLeave,
header: headerStyle,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
@@ -242,10 +253,11 @@ export const InCallView: FC<InCallViewProps> = ({
useReactionsSender();
useWakeLock();
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
if (connState === ConnectionState.Disconnected)
if (connectionState === ConnectionState.Disconnected)
throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null);
@@ -260,6 +272,21 @@ export const InCallView: FC<InCallViewProps> = ({
});
const muteAllAudio = useBehavior(muteAllAudio$);
// Call pickup state and display names are needed for waiting overlay/sounds
const callPickupState = useBehavior(vm.callPickupState$);
// Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache,
latencyHint: "interactive",
muted: muteAllAudio,
});
// This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
@@ -321,7 +348,90 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeave$, onLeave);
useSubscription(vm.autoLeave$, () => onLeave("user"));
// We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
useEffect((): (() => void) => {
// The CSS animation includes the delay, so we must double the length of the sound.
window.document.body.style.setProperty(
"--call-ring-duration-s",
`${ringDuration * 2}s`,
);
window.document.body.style.setProperty(
"--call-ring-delay-s",
`${ringDuration}s`,
);
// Remove properties when we unload.
return () => {
window.document.body.style.removeProperty("--call-ring-duration-s");
window.document.body.style.removeProperty("--call-ring-delay-s");
};
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
// When we enter timeout or decline we will leave the call.
useEffect((): void | (() => void) => {
if (callPickupState === "timeout") {
onLeave("user", "timeout");
}
if (callPickupState === "decline") {
onLeave("user", "decline");
}
}, [callPickupState, onLeave, pickupPhaseAudio]);
// When waiting for pickup, loop a waiting sound
useEffect((): void | (() => void) => {
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}, [callPickupState, pickupPhaseAudio, ringDuration]);
// Waiting UI overlay
const waitingOverlay: JSX.Element | null = useMemo(() => {
// No overlay if not in ringing state
if (callPickupState !== "ringing") return null;
// Use room state for other participants data (the one that we likely want to reach)
const roomOthers = [
...matrixRoom.getMembersWithMembership("join"),
...matrixRoom.getMembersWithMembership("invite"),
].filter((m) => m.userId !== client.getUserId());
// Yield if there are not other members in the room.
if (roomOthers.length === 0) return null;
const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined;
const isOneOnOne = roomOthers.length === 1 && otherMember;
const text = isOneOnOne
? `Waiting for ${otherMember.name ?? otherMember.userId} to join…`
: "Waiting for other participants…";
const avatarMxc = isOneOnOne
? (otherMember.getMxcAvatarUrl?.() ?? undefined)
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
return (
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
<div
className={classNames(overlayStyles.content, waitingStyles.content)}
>
<div className={waitingStyles.pulse}>
<Avatar
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
name={isOneOnOne ? otherMember.name : matrixRoom.name}
src={avatarMxc}
size={AvatarSize.XL}
/>
</div>
<Text size="md" className={waitingStyles.text}>
{text}
</Text>
</div>
</div>
);
}, [callPickupState, client, matrixRoom]);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -718,7 +828,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton
key="end_call"
onClick={function (): void {
onLeave();
onLeave("user");
}}
onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave"
@@ -801,6 +911,7 @@ export const InCallView: FC<InCallViewProps> = ({
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} />
{waitingOverlay}
{footer}
{layout.type !== "pip" && (
<>

View File

@@ -69,6 +69,8 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});

View File

@@ -0,0 +1,61 @@
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pulse {
position: relative;
height: 90px;
}
.pulse::before {
content: "";
position: absolute;
inset: -12px;
border-radius: 9999px;
border: 12px solid rgba(255, 255, 255, 0.6);
animation: pulse var(--call-ring-duration-s) ease-out infinite;
animation-delay: 1s;
opacity: 0;
}
.text {
color: var(--cpd-color-text-on-solid-primary);
}
@keyframes pulse {
0% {
transform: scale(0.95);
opacity: 0.7;
transform: scale(0);
opacity: 1;
}
35% {
transform: scale(1.15);
opacity: 0.15;
}
50% {
transform: scale(1.2);
opacity: 0;
}
50.01% {
transform: scale(0);
}
85% {
transform: scale(0);
}
100% {
transform: scale(0);
}
}

Binary file not shown.

Binary file not shown.

BIN
src/sound/call_declined.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_declined.ogg Normal file

Binary file not shown.

BIN
src/sound/call_timeout.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_timeout.ogg Normal file

Binary file not shown.

BIN
src/sound/ringtone.mp3 Normal file

Binary file not shown.

BIN
src/sound/ringtone.ogg Normal file

Binary file not shown.

View File

@@ -18,7 +18,16 @@ import {
of,
switchMap,
} from "rxjs";
import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk";
import {
ClientEvent,
SyncState,
type MatrixClient,
RoomEvent as MatrixRoomEvent,
MatrixEvent,
type IRoomTimelineData,
EventType,
type IEvent,
} from "matrix-js-sdk";
import {
ConnectionState,
type LocalParticipant,
@@ -237,6 +246,23 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
}
function mockRingEvent(
eventId: string,
lifetimeMs: number | undefined,
sender = local.userId,
): { event_id: string } & IRTCNotificationContent {
return {
event_id: eventId,
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
notification_type: "ring",
sender,
} as unknown as { event_id: string } & IRTCNotificationContent;
}
// The app doesn't really care about the content of these legacy events, we just
// need a value to fill in for them when emitting notifications
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
interface CallViewModelInputs {
remoteParticipants$: Behavior<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
@@ -1205,10 +1231,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as { event_id: string } & ICallNotifyContent,
mockRingEvent("$notif1", 30),
mockLegacyRingEvent,
);
},
});
@@ -1247,12 +1271,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 100 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif2", 100),
mockLegacyRingEvent,
);
},
});
@@ -1271,6 +1291,51 @@ describe("waitForCallPickup$", () => {
});
});
test("ringing -> unknown if we get disconnected", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const connectionState$ = new BehaviorSubject(ConnectionState.Connected);
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
withCallViewModel(
{
remoteParticipants$: behavior("a 19ms b", {
a: [],
b: [aliceParticipant],
}),
rtcMembers$: behavior("a 19ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
connectionState$,
},
(vm, rtcSession) => {
// Notify at 5ms so we enter ringing, then get disconnected 5ms later
schedule(" 5ms r 5ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$notif2", 100),
mockLegacyRingEvent,
);
},
d: () => {
connectionState$.next(ConnectionState.Disconnected);
},
});
expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", {
a: "unknown",
b: "ringing",
c: "unknown",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("success when someone joins before we notify", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Join at 10ms, notify later at 20ms (state should stay success)
@@ -1290,12 +1355,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 50 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif3", 50),
mockLegacyRingEvent,
);
},
});
@@ -1321,12 +1382,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{} as unknown as {
event_id: string;
} & IRTCNotificationContent, // no lifetime
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif4", undefined),
mockLegacyRingEvent,
);
},
});
@@ -1361,12 +1418,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif5", 30),
mockLegacyRingEvent,
);
},
});
@@ -1381,6 +1434,149 @@ describe("waitForCallPickup$", () => {
);
});
});
test("decline before timeout window ends -> decline", () => {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with 50ms lifetime, decline at 40ms with matching id
schedule(" 10ms r 29ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$decl1", 50),
mockLegacyRingEvent,
);
},
d: () => {
// Emit decline timeline event with id matching the notification
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl1",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms
schedule(" 10ms r 20ms t 10ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$decl2", 20),
mockLegacyRingEvent,
);
},
t: () => {},
d: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
function testStaysRinging(declineEvent: Partial<IEvent>): void {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with id A, decline arrives at 20ms with id B
schedule(" 10ms r 10ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$right", 50),
mockLegacyRingEvent,
);
},
d: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent(declineEvent),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
// We assert up to 21ms to see the ringing at 10ms and no change at 20ms
expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", {
a: "unknown",
b: "ringing",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
}
test("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging({
event_id: "$wrong",
type: "m.rtc.decline",
sender: local.userId,
});
});
test("decline with sender being the local user is ignored (stays ringing)", () => {
testStaysRinging({
event_id: "$right",
type: "m.rtc.decline",
sender: alice.userId,
});
});
});
test("audio output changes when toggling earpiece mode", () => {

View File

@@ -19,6 +19,9 @@ import {
} from "livekit-client";
import {
ClientEvent,
type EventTimelineSetHandlerMap,
EventType,
RoomEvent,
RoomStateEvent,
SyncState,
type Room as MatrixRoom,
@@ -33,6 +36,7 @@ import {
combineLatest,
concat,
distinctUntilChanged,
endWith,
filter,
forkJoin,
fromEvent,
@@ -45,6 +49,7 @@ import {
race,
scan,
skip,
skipWhile,
startWith,
switchAll,
switchMap,
@@ -58,9 +63,9 @@ import {
import { logger } from "matrix-js-sdk/lib/logger";
import {
type CallMembership,
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
Status,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -849,17 +854,6 @@ export class CallViewModel extends ViewModel {
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
public readonly leaveSoundEffect$ = this.userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/**
* The number of participants currently in the call.
*
@@ -887,17 +881,60 @@ export class CallViewModel extends ViewModel {
: NEVER;
/**
* Emits whenever the RTC session tells us that it intends to ring for a given
* duration.
* 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 beginRingingForMs$ = (
private readonly ring$: Observable<
Observable<"ringing" | "timeout" | "decline">
> = (
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<[IRTCNotificationContent]>
)
// event.lifetime is expected to be in ms
.pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0));
) 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,
),
),
),
endWith("decline" as const),
);
}),
);
/**
* Whether some Matrix user other than ourself is joined to the call.
@@ -910,46 +947,60 @@ export class CallViewModel extends ViewModel {
* The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* This may also be set if we are disconnected.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*/
public readonly callPickupState$ = this.options.waitForCallPickup
? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">(
concat(
concat(
// We don't know if the RTC session decides to send a notify event
// yet. It will only be known once we sent our own membership and
// know we were the first one to join.
of("unknown" as const),
// Once we get the signal to begin ringing:
this.beginRingingForMs$.pipe(
take(1),
switchMap((lifetime) =>
lifetime === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetime).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(this.someoneElseJoined$.pipe(filter((joined) => joined))),
),
of("success" as const),
public readonly callPickupState$: Behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> = this.options.waitForCallPickup
? this.scope.behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success"
>(
combineLatest([
this.livekitConnectionState$,
this.someoneElseJoined$,
]).pipe(
switchMap(([livekitConnectionState, someoneElseJoined]) => {
if (livekitConnectionState === ConnectionState.Disconnected) {
// Do not ring until we're connected.
return of("unknown" as const);
} else if (someoneElseJoined) {
return of("success" as const);
}
// Show the ringing state of the most recent ringing attempt.
return this.ring$.pipe(switchAll());
}),
// The state starts as 'unknown' because we don't know if the RTC
// session will actually send a notify event yet. It will only be
// known once we send our own membership and see that we were the
// first one to join.
startWith("unknown" as const),
),
)
: constant(null);
public readonly leaveSoundEffect$ = combineLatest([
this.callPickupState$,
this.userMedia$,
]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
@@ -1639,7 +1690,7 @@ export class CallViewModel extends ViewModel {
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly options: CallViewModelOptions,
private readonly livekitConnectionState$: Observable<ECConnectionState>,
public readonly livekitConnectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo>
>,

View File

@@ -32,6 +32,8 @@ async function playSound(
buffer: AudioBuffer,
volume: number,
stereoPan: number,
delayS = 0,
abort?: AbortController,
): Promise<void> {
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0);
@@ -39,13 +41,62 @@ async function playSound(
pan.pan.setValueAtTime(stereoPan, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(gain).connect(pan).connect(ctx.destination);
abort?.signal.addEventListener("abort", () => {
src.disconnect();
});
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.connect(gain).connect(pan).connect(ctx.destination);
controls.setPlaybackStarted();
src.start();
src.start(ctx.currentTime + delayS);
return p;
}
/**
* Play a sound though a given AudioContext, looping until stopped. Will take
* care of connecting the correct buffer and gating
* through gain.
* @param volume The volume to play at.
* @param ctx The context to play through.
* @param buffer The buffer to play.
* @returns A function used to end the sound. This function will return a promise when the sound has stopped.
*/
function playSoundLooping(
ctx: AudioContext,
buffer: AudioBuffer,
volume: number,
stereoPan: number,
delayS?: number,
): () => Promise<void> {
if (delayS === 0) {
throw Error("Looping sounds must have a delay");
}
// Our audio loop
let lastSoundPromise: Promise<void>;
let nextSoundPromise: Promise<void>;
let ac: AbortController | undefined;
void (async (): Promise<void> => {
ac = new AbortController();
// Play a sound immediately
lastSoundPromise = Promise.resolve();
do {
// Queue up the next sound.
nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac);
// Await the previous sound.
await lastSoundPromise;
// Swap the promises over, and loop round to play the next sound.
lastSoundPromise = nextSoundPromise;
} while (!ac.signal.aborted);
})();
return async () => {
ac?.abort();
// Wait for sounds to finish.
await lastSoundPromise;
await nextSoundPromise;
};
}
interface Props<S extends string> {
/**
* The sounds to play. If no sounds should be played then
@@ -57,8 +108,13 @@ interface Props<S extends string> {
muted?: boolean;
}
interface UseAudioContext<S> {
interface UseAudioContext<S extends string> {
playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/**
* Map of sound name to duration in seconds.
*/
soundDuration: Record<string, number>;
}
/**
@@ -146,5 +202,23 @@ export function useAudioContext<S extends string>(
earpiecePan,
);
},
playSoundLooping: (name, delayS: number): (() => Promise<void>) => {
if (!audioBuffers[name]) {
throw Error(`Tried to play a sound that wasn't buffered (${name})`);
}
return playSoundLooping(
audioContext,
audioBuffers[name],
soundEffectVolume * earpieceVolume,
earpiecePan,
delayS,
);
},
soundDuration: Object.fromEntries(
Object.entries(audioBuffers).map(([k, v]) => [
k,
(v as AudioBuffer).duration,
]),
),
};
}

View File

@@ -22,7 +22,10 @@ import {
} from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import {
CallViewModel,
type CallViewModelOptions,
} from "../state/CallViewModel";
import {
mockLivekitRoom,
mockMatrixRoom,
@@ -122,6 +125,7 @@ export function getBasicRTCSession(
export function getBasicCallViewModelEnvironment(
members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
callViewModelOptions: Partial<CallViewModelOptions> = {},
): {
vm: CallViewModel;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
@@ -148,6 +152,7 @@ export function getBasicCallViewModelEnvironment(
mockMediaDevices({}),
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
...callViewModelOptions,
},
of(ConnectionState.Connected),
handRaisedSubject$,

View File

@@ -38,6 +38,7 @@ import {
type RoomAndToDeviceEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type TrackReference } from "@livekit/components-core";
import EventEmitter from "events";
import {
LocalUserMediaViewModel,
@@ -144,26 +145,25 @@ export function withTestScheduler(
}
interface EmitterMock<T> {
on: () => T;
off: () => T;
addListener: () => T;
removeListener: () => T;
on: (...args: unknown[]) => T;
off: (...args: unknown[]) => T;
addListener: (...args: unknown[]) => T;
removeListener: (...args: unknown[]) => T;
emit: (event: string | symbol, ...args: unknown[]) => boolean;
}
export function mockEmitter<T>(): EmitterMock<T> {
const ee = new EventEmitter();
return {
on(): T {
return this as T;
},
off(): T {
return this as T;
},
addListener(): T {
return this as T;
},
removeListener(): T {
return this as T;
},
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
addListener: ee.addListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
removeListener: ee.removeListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
emit: ee.emit.bind(ee),
};
}

View File

@@ -116,6 +116,7 @@ export const widget = ((): WidgetHelpers | null => {
EventType.Reaction,
EventType.RoomRedaction,
ElementCallReactionEventType,
EventType.RTCDecline,
];
const sendState = [

302
yarn.lock
View File

@@ -4503,142 +4503,149 @@ __metadata:
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.41.1"
"@rollup/rollup-android-arm-eabi@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-android-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm64@npm:4.41.1"
"@rollup/rollup-android-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm64@npm:4.50.1"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.41.1"
"@rollup/rollup-darwin-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-x64@npm:4.41.1"
"@rollup/rollup-darwin-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-x64@npm:4.50.1"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.41.1"
"@rollup/rollup-freebsd-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.41.1"
"@rollup/rollup-freebsd-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1"
"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-musleabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1"
"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1"
conditions: os=linux & cpu=arm & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.41.1"
"@rollup/rollup-linux-arm64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.41.1"
"@rollup/rollup-linux-arm64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1"
"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1"
conditions: os=linux & cpu=loong64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1"
"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1"
"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.41.1"
"@rollup/rollup-linux-riscv64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-s390x-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.41.1"
"@rollup/rollup-linux-s390x-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.41.1"
"@rollup/rollup-linux-x64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.41.1"
"@rollup/rollup-linux-x64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.41.1"
"@rollup/rollup-openharmony-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-ia32-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.41.1"
"@rollup/rollup-win32-ia32-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rollup/rollup-win32-x64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.41.1"
"@rollup/rollup-win32-x64-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -5148,10 +5155,10 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:1.0.7":
version: 1.0.7
resolution: "@types/estree@npm:1.0.7"
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
"@types/estree@npm:1.0.8":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
languageName: node
linkType: hard
@@ -7558,7 +7565,7 @@ __metadata:
typescript-eslint-language-service: "npm:^5.0.5"
unique-names-generator: "npm:^4.6.0"
vaul: "npm:^1.0.0"
vite: "npm:^6.0.0"
vite: "npm:^7.0.0"
vite-plugin-generate-file: "npm:^0.3.0"
vite-plugin-html: "npm:^3.2.2"
vite-plugin-svgr: "npm:^4.0.0"
@@ -8483,6 +8490,18 @@ __metadata:
languageName: node
linkType: hard
"fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f
languageName: node
linkType: hard
"fflate@npm:^0.4.8":
version: 0.4.8
resolution: "fflate@npm:0.4.8"
@@ -10280,7 +10299,7 @@ __metadata:
"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=c4c7f945141e142e6f846b243c33c4af97a9a44b"
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2f1d654f14be8dd03896e9e76f12017b6f9eec1c"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
@@ -10296,7 +10315,7 @@ __metadata:
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a
checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805
languageName: node
linkType: hard
@@ -11097,6 +11116,13 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^4.0.3":
version: 4.0.3
resolution: "picomatch@npm:4.0.3"
checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2
languageName: node
linkType: hard
"playwright-core@npm:1.54.1":
version: 1.54.1
resolution: "playwright-core@npm:1.54.1"
@@ -11523,7 +11549,7 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.41, postcss@npm:^8.5.3":
"postcss@npm:^8.4.41, postcss@npm:^8.5.6":
version: 8.5.6
resolution: "postcss@npm:8.5.6"
dependencies:
@@ -12251,31 +12277,32 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^4.34.9, rollup@npm:^4.40.0":
version: 4.41.1
resolution: "rollup@npm:4.41.1"
"rollup@npm:^4.43.0":
version: 4.50.1
resolution: "rollup@npm:4.50.1"
dependencies:
"@rollup/rollup-android-arm-eabi": "npm:4.41.1"
"@rollup/rollup-android-arm64": "npm:4.41.1"
"@rollup/rollup-darwin-arm64": "npm:4.41.1"
"@rollup/rollup-darwin-x64": "npm:4.41.1"
"@rollup/rollup-freebsd-arm64": "npm:4.41.1"
"@rollup/rollup-freebsd-x64": "npm:4.41.1"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.41.1"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.41.1"
"@rollup/rollup-linux-arm64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-arm64-musl": "npm:4.41.1"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-powerpc64le-gnu": "npm:4.41.1"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-riscv64-musl": "npm:4.41.1"
"@rollup/rollup-linux-s390x-gnu": "npm:4.41.1"
"@rollup/rollup-linux-x64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-x64-musl": "npm:4.41.1"
"@rollup/rollup-win32-arm64-msvc": "npm:4.41.1"
"@rollup/rollup-win32-ia32-msvc": "npm:4.41.1"
"@rollup/rollup-win32-x64-msvc": "npm:4.41.1"
"@types/estree": "npm:1.0.7"
"@rollup/rollup-android-arm-eabi": "npm:4.50.1"
"@rollup/rollup-android-arm64": "npm:4.50.1"
"@rollup/rollup-darwin-arm64": "npm:4.50.1"
"@rollup/rollup-darwin-x64": "npm:4.50.1"
"@rollup/rollup-freebsd-arm64": "npm:4.50.1"
"@rollup/rollup-freebsd-x64": "npm:4.50.1"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.50.1"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.50.1"
"@rollup/rollup-linux-arm64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-arm64-musl": "npm:4.50.1"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-ppc64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-riscv64-musl": "npm:4.50.1"
"@rollup/rollup-linux-s390x-gnu": "npm:4.50.1"
"@rollup/rollup-linux-x64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-x64-musl": "npm:4.50.1"
"@rollup/rollup-openharmony-arm64": "npm:4.50.1"
"@rollup/rollup-win32-arm64-msvc": "npm:4.50.1"
"@rollup/rollup-win32-ia32-msvc": "npm:4.50.1"
"@rollup/rollup-win32-x64-msvc": "npm:4.50.1"
"@types/estree": "npm:1.0.8"
fsevents: "npm:~2.3.2"
dependenciesMeta:
"@rollup/rollup-android-arm-eabi":
@@ -12300,7 +12327,7 @@ __metadata:
optional: true
"@rollup/rollup-linux-loongarch64-gnu":
optional: true
"@rollup/rollup-linux-powerpc64le-gnu":
"@rollup/rollup-linux-ppc64-gnu":
optional: true
"@rollup/rollup-linux-riscv64-gnu":
optional: true
@@ -12312,6 +12339,8 @@ __metadata:
optional: true
"@rollup/rollup-linux-x64-musl":
optional: true
"@rollup/rollup-openharmony-arm64":
optional: true
"@rollup/rollup-win32-arm64-msvc":
optional: true
"@rollup/rollup-win32-ia32-msvc":
@@ -12322,7 +12351,7 @@ __metadata:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 10c0/c4d5f2257320b50dc0e035e31d8d2f78d36b7015aef2f87cc984c0a1c97ffebf14337dddeb488b4b11ae798fea6486189b77e7cf677617dcf611d97db41ebfda
checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205
languageName: node
linkType: hard
@@ -13158,7 +13187,7 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14":
"tinyglobby@npm:^0.2.14":
version: 0.2.14
resolution: "tinyglobby@npm:0.2.14"
dependencies:
@@ -13168,6 +13197,16 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.3"
checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844
languageName: node
linkType: hard
"tinypool@npm:^1.1.1":
version: 1.1.1
resolution: "tinypool@npm:1.1.1"
@@ -13886,17 +13925,17 @@ __metadata:
languageName: node
linkType: hard
"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0":
version: 7.0.0-beta.0
resolution: "vite@npm:7.0.0-beta.0"
"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0":
version: 7.1.5
resolution: "vite@npm:7.1.5"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fdir: "npm:^6.5.0"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.40.0"
tinyglobby: "npm:^0.2.14"
picomatch: "npm:^4.0.3"
postcss: "npm:^8.5.6"
rollup: "npm:^4.43.0"
tinyglobby: "npm:^0.2.15"
peerDependencies:
"@types/node": ^20.19.0 || >=22.12.0
jiti: ">=1.21.0"
@@ -13937,62 +13976,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/d13907d67b4991a2862dafe6a31d10ffe28f26ba04e511049e9d86d06293b3a8d6733c896c8fb38e3f2d5805d240e3cad27700f3c42536602035e4c324b48d58
languageName: node
linkType: hard
"vite@npm:^6.0.0":
version: 6.3.5
resolution: "vite@npm:6.3.5"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.34.9"
tinyglobby: "npm:^0.2.13"
peerDependencies:
"@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: ">=1.21.0"
less: "*"
lightningcss: ^1.21.0
sass: "*"
sass-embedded: "*"
stylus: "*"
sugarss: "*"
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
dependenciesMeta:
fsevents:
optional: true
peerDependenciesMeta:
"@types/node":
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c
checksum: 10c0/782d2f20c25541b26d1fb39bef5f194149caff39dc25b7836e25f049ca919f2e2ce186bddb21f3f20f6195354b3579ec637a8ca08d65b117f8b6f81e3e730a9c
languageName: node
linkType: hard