mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
34 Commits
toger5/wai
...
hs/error-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a130ac70 | ||
|
|
82392bf582 | ||
|
|
f072fd2f9c | ||
|
|
c00298f4a5 | ||
|
|
0721d48c20 | ||
|
|
b16ba52e13 | ||
|
|
e201258af3 | ||
|
|
76465d0e63 | ||
|
|
a2cf4c8139 | ||
|
|
65d358df58 | ||
|
|
32cb8541f4 | ||
|
|
62cfe09c7b | ||
|
|
8e32deaba5 | ||
|
|
8311b1619c | ||
|
|
fc449bcfbf | ||
|
|
34a8977dd1 | ||
|
|
db04cbfbfc | ||
|
|
c8d3d58940 | ||
|
|
d85cf5f929 | ||
|
|
1e32b355ce | ||
|
|
1193a22658 | ||
|
|
2541f810fa | ||
|
|
7961bb35be | ||
|
|
075a4cbe0d | ||
|
|
cd845b69d2 | ||
|
|
779f792cbf | ||
|
|
e9c43856d0 | ||
|
|
7724cbf9be | ||
|
|
fe65c1f4da | ||
|
|
a91c71a8e4 | ||
|
|
e30142a43b | ||
|
|
30024877fc | ||
|
|
aa0f3a1126 | ||
|
|
735d63d393 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -69,6 +69,8 @@ beforeEach(() => {
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
61
src/room/WaitingForJoin.module.css
Normal file
61
src/room/WaitingForJoin.module.css
Normal 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
BIN
src/sound/call_declined.mp3
Normal file
Binary file not shown.
BIN
src/sound/call_declined.ogg
Normal file
BIN
src/sound/call_declined.ogg
Normal file
Binary file not shown.
BIN
src/sound/call_timeout.mp3
Normal file
BIN
src/sound/call_timeout.mp3
Normal file
Binary file not shown.
BIN
src/sound/call_timeout.ogg
Normal file
BIN
src/sound/call_timeout.ogg
Normal file
Binary file not shown.
BIN
src/sound/ringtone.mp3
Normal file
BIN
src/sound/ringtone.mp3
Normal file
Binary file not shown.
BIN
src/sound/ringtone.ogg
Normal file
BIN
src/sound/ringtone.ogg
Normal file
Binary file not shown.
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
>,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
ElementCallReactionEventType,
|
||||
EventType.RTCDecline,
|
||||
];
|
||||
|
||||
const sendState = [
|
||||
|
||||
302
yarn.lock
302
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user