mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
50 Commits
af/oidc-na
...
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 | ||
|
|
c599d6eae9 | ||
|
|
075a4cbe0d | ||
|
|
cd845b69d2 | ||
|
|
779f792cbf | ||
|
|
e9c43856d0 | ||
|
|
1e8b87408c | ||
|
|
1428df8567 | ||
|
|
b8acdc3cec | ||
|
|
880e07c07f | ||
|
|
07522d6704 | ||
|
|
58ed7ef93e | ||
|
|
7724cbf9be | ||
|
|
fe65c1f4da | ||
|
|
a91c71a8e4 | ||
|
|
e30142a43b | ||
|
|
c15551c9f5 | ||
|
|
f410a11934 | ||
|
|
3122ccf4e4 | ||
|
|
03527e9d7a | ||
|
|
36be15436a | ||
|
|
04c03b1e72 | ||
|
|
fb8de061f3 | ||
|
|
acd26c61ef | ||
|
|
e475f56af5 | ||
|
|
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
|
||||
|
||||
@@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -216,6 +216,17 @@ export interface UrlConfiguration {
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
@@ -336,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,
|
||||
@@ -347,6 +358,7 @@ export const getUrlParams = (
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification" as RTCNotificationType,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
@@ -366,6 +378,7 @@ export const getUrlParams = (
|
||||
...inAppDefault,
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
};
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
@@ -391,6 +404,7 @@ export const getUrlParams = (
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -442,6 +456,7 @@ export const getUrlParams = (
|
||||
"ring",
|
||||
"notification",
|
||||
]),
|
||||
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
||||
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||
};
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("Leaking connection prevention", () => {
|
||||
test("Should cancel pending connections when the component is unmounted", async () => {
|
||||
const connectCall = vi.fn();
|
||||
const pendingConnection = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
|
||||
const mockRoom = {
|
||||
@@ -142,7 +142,7 @@ describe("Leaking connection prevention", () => {
|
||||
test("Should cancel about to open but not yet opened connection", async () => {
|
||||
const createTracksCall = vi.fn();
|
||||
const pendingCreateTrack = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ import { act } from "react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { mockRtcMembership } from "../utils/test";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
@@ -33,6 +30,7 @@ import {
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
@@ -56,6 +54,8 @@ beforeEach(() => {
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,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++) {
|
||||
@@ -158,6 +172,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
playSound.mockClear();
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -172,7 +187,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -182,5 +197,5 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { filter, interval, throttle } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel";
|
||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||
@@ -17,15 +16,14 @@ 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";
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
export const callEventAudioSounds = prefetchSounds({
|
||||
join: {
|
||||
mp3: joinCallSoundMp3,
|
||||
@@ -43,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,
|
||||
@@ -60,37 +68,18 @@ export function CallEventAudioRenderer({
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
useEffect(() => {
|
||||
const joinSub = vm.participantChanges$
|
||||
.pipe(
|
||||
filter(
|
||||
({ joined, ids }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("join");
|
||||
});
|
||||
|
||||
const leftSub = vm.participantChanges$
|
||||
.pipe(
|
||||
filter(
|
||||
({ ids, left }) =>
|
||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
|
||||
),
|
||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("left");
|
||||
});
|
||||
|
||||
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("raiseHand");
|
||||
});
|
||||
|
||||
const screenshareSub = vm.newScreenShare$.subscribe(() => {
|
||||
void audioEngineRef.current?.playSound("screenshareStarted");
|
||||
});
|
||||
const joinSub = vm.joinSoundEffect$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("join"),
|
||||
);
|
||||
const leftSub = vm.leaveSoundEffect$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("left"),
|
||||
);
|
||||
const handRaisedSub = vm.newHandRaised$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("raiseHand"),
|
||||
);
|
||||
const screenshareSub = vm.newScreenShare$.subscribe(
|
||||
() => void audioEngineRef.current?.playSound("screenshareStarted"),
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
joinSub.unsubscribe();
|
||||
|
||||
@@ -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;
|
||||
@@ -453,7 +453,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
matrixRoom={room}
|
||||
participantCount={participantCount}
|
||||
onLeave={onLeave}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
@@ -495,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;
|
||||
@@ -503,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";
|
||||
@@ -177,11 +177,9 @@ function createInCallView(): RenderResult & {
|
||||
}}
|
||||
matrixRoom={room}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
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>
|
||||
);
|
||||
@@ -216,12 +229,10 @@ export interface InCallViewProps {
|
||||
matrixRoom: MatrixRoom;
|
||||
livekitRoom: LivekitRoom;
|
||||
muteStates: MuteStates;
|
||||
participantCount: number;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -233,10 +244,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
header: headerStyle,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -244,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);
|
||||
@@ -262,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);
|
||||
@@ -312,6 +337,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
() => void toggleRaisedHand(),
|
||||
);
|
||||
|
||||
const participantCount = useBehavior(vm.participantCount$);
|
||||
const reconnecting = useBehavior(vm.reconnecting$);
|
||||
const windowMode = useBehavior(vm.windowMode$);
|
||||
const layout = useBehavior(vm.layout$);
|
||||
@@ -322,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.autoLeaveWhenOthersLeft$, 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
|
||||
@@ -719,7 +828,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<EndCallButton
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
onLeave();
|
||||
onLeave("user");
|
||||
}}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_leave"
|
||||
@@ -802,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);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,34 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="participantsLine"
|
||||
>
|
||||
<svg
|
||||
aria-label="Participants"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
|
||||
/>
|
||||
<path
|
||||
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
|
||||
data-testid="roomHeader_participants_count"
|
||||
>
|
||||
2
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
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.
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi, onTestFinished, it } from "vitest";
|
||||
import { test, vi, onTestFinished, it, describe } from "vitest";
|
||||
import EventEmitter from "events";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -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,
|
||||
@@ -32,6 +41,9 @@ import {
|
||||
Status,
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
type IRTCNotificationContent,
|
||||
type ICallNotifyContent,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
@@ -234,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>[]>;
|
||||
@@ -308,7 +337,7 @@ function withCallViewModel(
|
||||
|
||||
const roomEventSelectorSpy = vi
|
||||
.spyOn(ComponentsCore, "roomEventSelector")
|
||||
.mockImplementation((room, eventType) => of());
|
||||
.mockImplementation((_room, _eventType) => of());
|
||||
|
||||
const livekitRoom = mockLivekitRoom(
|
||||
{ localParticipant },
|
||||
@@ -1068,9 +1097,9 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
});
|
||||
|
||||
function nooneEverThere$<T>(
|
||||
hot: (marbles: string, values: Record<string, T[]>) => Observable<T[]>,
|
||||
): Observable<T[]> {
|
||||
return hot("a-b-c-d", {
|
||||
behavior: (marbles: string, values: Record<string, T[]>) => Behavior<T[]>,
|
||||
): Behavior<T[]> {
|
||||
return behavior("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [], // Alice joins
|
||||
c: [], // Alice still there
|
||||
@@ -1079,12 +1108,12 @@ function nooneEverThere$<T>(
|
||||
}
|
||||
|
||||
function participantJoinLeave$(
|
||||
hot: (
|
||||
behavior: (
|
||||
marbles: string,
|
||||
values: Record<string, RemoteParticipant[]>,
|
||||
) => Observable<RemoteParticipant[]>,
|
||||
): Observable<RemoteParticipant[]> {
|
||||
return hot("a-b-c-d", {
|
||||
) => Behavior<RemoteParticipant[]>,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
return behavior("a-b-c-d", {
|
||||
a: [], // Start empty
|
||||
b: [aliceParticipant], // Alice joins
|
||||
c: [aliceParticipant], // Alice still there
|
||||
@@ -1093,12 +1122,12 @@ function participantJoinLeave$(
|
||||
}
|
||||
|
||||
function rtcMemberJoinLeave$(
|
||||
hot: (
|
||||
behavior: (
|
||||
marbles: string,
|
||||
values: Record<string, CallMembership[]>,
|
||||
) => Observable<CallMembership[]>,
|
||||
): Observable<CallMembership[]> {
|
||||
return hot("a-b-c-d", {
|
||||
) => Behavior<CallMembership[]>,
|
||||
): Behavior<CallMembership[]> {
|
||||
return behavior("a-b-c-d", {
|
||||
a: [localRtcMember], // Start empty
|
||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||
c: [localRtcMember, aliceRtcMember], // Alice still there
|
||||
@@ -1106,47 +1135,15 @@ function rtcMemberJoinLeave$(
|
||||
});
|
||||
}
|
||||
|
||||
test("allOthersLeft$ emits only when someone joined and then all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
|
||||
withCallViewModel(
|
||||
{ remoteParticipants$: scope.behavior(nooneEverThere$(hot), []) },
|
||||
(vm) => {
|
||||
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("allOthersLeft$ emits true when someone joined and then all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
test("autoLeave$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
remoteParticipants$: participantJoinLeave$(behavior),
|
||||
rtcMembers$: rtcMemberJoinLeave$(behavior),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(vm.allOthersLeft$).toBe(
|
||||
"n-----u", // false initially, then at frame 6: true then false emissions in same frame
|
||||
{ n: false, u: true }, // map(() => {})
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
|
||||
"------e", // false initially, then at frame 6: true then false emissions in same frame
|
||||
{ e: undefined },
|
||||
);
|
||||
expectObservable(vm.autoLeave$).toBe("------(e|)", { e: undefined });
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
@@ -1156,15 +1153,15 @@ test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
test("autoLeave$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: scope.behavior(nooneEverThere$(hot), []),
|
||||
rtcMembers$: scope.behavior(nooneEverThere$(hot), []),
|
||||
remoteParticipants$: nooneEverThere$(behavior),
|
||||
rtcMembers$: nooneEverThere$(behavior),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||
expectObservable(vm.autoLeave$).toBe("-");
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
@@ -1174,15 +1171,15 @@ test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is ena
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
test("autoLeave$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: scope.behavior(participantJoinLeave$(hot), []),
|
||||
rtcMembers$: scope.behavior(rtcMemberJoinLeave$(hot), []),
|
||||
remoteParticipants$: participantJoinLeave$(behavior),
|
||||
rtcMembers$: rtcMemberJoinLeave$(behavior),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
|
||||
expectObservable(vm.autoLeave$).toBe("-");
|
||||
},
|
||||
{
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
@@ -1192,31 +1189,25 @@ test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option
|
||||
});
|
||||
});
|
||||
|
||||
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
||||
withTestScheduler(({ hot, expectObservable, scope }) => {
|
||||
test("autoLeave$ emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: scope.behavior(
|
||||
hot("a-b-c-d", {
|
||||
a: [], // Alone
|
||||
b: [aliceParticipant], // Alice joins
|
||||
c: [aliceParticipant],
|
||||
d: [], // Local joins with a second device
|
||||
}),
|
||||
[], //Alice leaves
|
||||
),
|
||||
rtcMembers$: scope.behavior(
|
||||
hot("a-b-c-d", {
|
||||
a: [localRtcMember], // Start empty
|
||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
|
||||
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
|
||||
}),
|
||||
[],
|
||||
),
|
||||
remoteParticipants$: behavior("a-b-c-d", {
|
||||
a: [], // Alone
|
||||
b: [aliceParticipant], // Alice joins
|
||||
c: [aliceParticipant],
|
||||
d: [], // Local joins with a second device
|
||||
}),
|
||||
rtcMembers$: behavior("a-b-c-d", {
|
||||
a: [localRtcMember], // Start empty
|
||||
b: [localRtcMember, aliceRtcMember], // Alice joins
|
||||
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
|
||||
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
|
||||
}),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
|
||||
expectObservable(vm.autoLeave$).toBe("------(e|)", {
|
||||
e: undefined,
|
||||
});
|
||||
},
|
||||
@@ -1228,6 +1219,366 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
test("unknown -> ringing -> timeout when notified and nobody joins", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
// No one ever joins (only local user)
|
||||
withCallViewModel(
|
||||
{ remoteParticipants$: constant([]) },
|
||||
(vm, rtcSession) => {
|
||||
// Fire a call notification at 10ms with lifetime 30ms
|
||||
schedule(" 10ms r", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "timeout",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("ringing -> success if someone joins before timeout", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior("a 19ms b", {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
}),
|
||||
rtcMembers$: behavior("a 19ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
// Notify at 5ms so we enter ringing, then success at 20ms
|
||||
schedule(" 5ms r", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif2", 100),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expectObservable(vm.callPickupState$).toBe("a 4ms b 14ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "success",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior("a 9ms b", {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
}),
|
||||
rtcMembers$: behavior("a 9ms b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
schedule(" 20ms r", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif3", 50),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "success",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("notify without lifetime -> immediate timeout", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{},
|
||||
(vm, rtcSession) => {
|
||||
schedule(" 10ms r", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif4", undefined),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "timeout",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("stays null when waitForCallPickup=false", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior("a--b", {
|
||||
a: [],
|
||||
b: [aliceParticipant],
|
||||
}),
|
||||
rtcMembers$: behavior("a--b", {
|
||||
a: [localRtcMember],
|
||||
b: [localRtcMember, aliceRtcMember],
|
||||
}),
|
||||
},
|
||||
(vm, rtcSession) => {
|
||||
schedule(" 5ms r", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif5", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.callPickupState$).toBe("(n)", {
|
||||
n: null,
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: false,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from "livekit-client";
|
||||
import {
|
||||
ClientEvent,
|
||||
type EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RoomEvent,
|
||||
RoomStateEvent,
|
||||
SyncState,
|
||||
type Room as MatrixRoom,
|
||||
@@ -27,26 +30,33 @@ import {
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
NEVER,
|
||||
type Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
endWith,
|
||||
filter,
|
||||
forkJoin,
|
||||
fromEvent,
|
||||
ignoreElements,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
of,
|
||||
pairwise,
|
||||
race,
|
||||
scan,
|
||||
skip,
|
||||
skipWhile,
|
||||
startWith,
|
||||
switchAll,
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
takeUntil,
|
||||
throttleTime,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
@@ -55,6 +65,7 @@ import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -105,16 +116,27 @@ import { observeSpeaker$ } from "./observeSpeaker";
|
||||
import { shallowEquals } from "../utils/array";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { constant, type Behavior } from "./Behavior";
|
||||
|
||||
export interface CallViewModelOptions {
|
||||
encryptionSystem: EncryptionSystem;
|
||||
autoLeaveWhenOthersLeft?: boolean;
|
||||
/**
|
||||
* If the call is started in a way where we want it to behave like a telephone usecase
|
||||
* If we sent a notification event, we want the ui to show a ringing state
|
||||
*/
|
||||
waitForCallPickup?: boolean;
|
||||
}
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||
|
||||
// This is the number of participants that we think constitutes a "small" call
|
||||
// on mobile. No spotlight tile should be shown below this threshold.
|
||||
const smallMobileCallThreshold = 3;
|
||||
@@ -555,6 +577,17 @@ export class CallViewModel extends ViewModel {
|
||||
)
|
||||
.pipe(pauseWhen(this.pretendToBeDisconnected$));
|
||||
|
||||
private readonly memberships$ = this.scope.behavior(
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
).pipe(
|
||||
startWith(null),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
map(() => this.matrixRTCSession.memberships),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Displaynames for each member of the call. This will disambiguate
|
||||
* any displaynames that clashes with another member. Only members
|
||||
@@ -564,18 +597,17 @@ export class CallViewModel extends ViewModel {
|
||||
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
|
||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||
public readonly memberDisplaynames$ = this.scope.behavior(
|
||||
merge(
|
||||
// Handle call membership changes.
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
),
|
||||
// Handle room membership changes (and displayname updates)
|
||||
fromEvent(this.matrixRoom, RoomStateEvent.Members),
|
||||
).pipe(
|
||||
startWith(null),
|
||||
map(() => {
|
||||
const memberships = this.matrixRTCSession.memberships;
|
||||
// React to call memberships and also display name updates
|
||||
// (calculateDisplayName implicitly depends on the room member data)
|
||||
combineLatest(
|
||||
[
|
||||
this.memberships$,
|
||||
fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe(
|
||||
startWith(null),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
),
|
||||
],
|
||||
(memberships, _members) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
const room = this.matrixRoom;
|
||||
|
||||
@@ -597,8 +629,7 @@ export class CallViewModel extends ViewModel {
|
||||
);
|
||||
}
|
||||
return displaynameMap;
|
||||
}),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -628,13 +659,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.remoteParticipants$,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
duplicateTiles.value$,
|
||||
// Also react to changes in the MatrixRTC session list.
|
||||
// The session list will also be update if a room membership changes.
|
||||
// No additional RoomState event listener needs to be set up.
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
).pipe(startWith(null), pauseWhen(this.pretendToBeDisconnected$)),
|
||||
this.memberships$,
|
||||
showNonMemberTiles.value$,
|
||||
]).pipe(
|
||||
scan(
|
||||
@@ -644,7 +669,7 @@ export class CallViewModel extends ViewModel {
|
||||
remoteParticipants,
|
||||
{ participant: localParticipant },
|
||||
duplicateTiles,
|
||||
_membershipsChanged,
|
||||
memberships,
|
||||
showNonMemberTiles,
|
||||
],
|
||||
) => {
|
||||
@@ -652,7 +677,7 @@ export class CallViewModel extends ViewModel {
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
const room = this.matrixRoom;
|
||||
// m.rtc.members are the basis for calculating what is visible in the call
|
||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||
for (const rtcMember of memberships) {
|
||||
const { member, id: livekitParticipantId } =
|
||||
getRoomMemberFromRtcMember(rtcMember, room);
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
@@ -818,77 +843,162 @@ export class CallViewModel extends ViewModel {
|
||||
),
|
||||
);
|
||||
|
||||
public readonly joinSoundEffect$ = this.userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
|
||||
current.length > prev.length,
|
||||
),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* This observable tracks the currently connected participants.
|
||||
* The number of participants currently in the call.
|
||||
*
|
||||
* - Each participant has one livekit connection
|
||||
* - Each participant has a corresponding MatrixRTC membership state event
|
||||
* - There can be multiple participants for one matrix user.
|
||||
* - There can be multiple participants for one Matrix user if they join from
|
||||
* multiple devices.
|
||||
*/
|
||||
public readonly participantChanges$ = this.userMedia$.pipe(
|
||||
map((mediaItems) => mediaItems.map((m) => m.id)),
|
||||
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
||||
(prev, ids) => {
|
||||
const left = prev.ids.filter((id) => !ids.includes(id));
|
||||
const joined = ids.filter((id) => !prev.ids.includes(id));
|
||||
return { ids, joined, left };
|
||||
},
|
||||
{ ids: [], joined: [], left: [] },
|
||||
),
|
||||
public readonly participantCount$ = this.scope.behavior(
|
||||
this.memberships$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
private readonly allOthersLeft$ = this.memberships$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.every((m) => m.sender === this.userId) &&
|
||||
prev.some((m) => m.sender !== this.userId),
|
||||
),
|
||||
map(() => {}),
|
||||
take(1),
|
||||
);
|
||||
|
||||
public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft
|
||||
? this.allOthersLeft$
|
||||
: NEVER;
|
||||
|
||||
/**
|
||||
* This observable tracks the matrix users that are currently in the call.
|
||||
* There can be just one matrix user with multiple participants (see also participantChanges$)
|
||||
* 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.
|
||||
*/
|
||||
public readonly matrixUserChanges$ = this.userMedia$.pipe(
|
||||
map(
|
||||
(mediaItems) =>
|
||||
new Set(
|
||||
mediaItems
|
||||
.map((m) => m.vm.member?.userId)
|
||||
.filter((id) => id !== undefined),
|
||||
private readonly ring$: Observable<
|
||||
Observable<"ringing" | "timeout" | "decline">
|
||||
> = (
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
) as Observable<
|
||||
Parameters<
|
||||
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
|
||||
>
|
||||
>
|
||||
).pipe(
|
||||
filter(
|
||||
([notificationEvent]) => notificationEvent.notification_type === "ring",
|
||||
),
|
||||
map(([notificationEvent]) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
EMPTY
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetimeMs).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has likely
|
||||
// stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(
|
||||
(
|
||||
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>
|
||||
).pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getType() === EventType.RTCDecline &&
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id === notificationEvent.event_id &&
|
||||
event.getSender() !== this.userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<
|
||||
Set<string>,
|
||||
{
|
||||
userIds: Set<string>;
|
||||
joinedUserIds: Set<string>;
|
||||
leftUserIds: Set<string>;
|
||||
}
|
||||
>(
|
||||
(prevState, userIds) => {
|
||||
const left = new Set(
|
||||
[...prevState.userIds].filter((id) => !userIds.has(id)),
|
||||
);
|
||||
const joined = new Set(
|
||||
[...userIds].filter((id) => !prevState.userIds.has(id)),
|
||||
);
|
||||
return { userIds: userIds, joinedUserIds: joined, leftUserIds: left };
|
||||
},
|
||||
{ userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() },
|
||||
),
|
||||
);
|
||||
|
||||
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
|
||||
map(({ userIds, leftUserIds }) => {
|
||||
if (!this.userId) {
|
||||
logger.warn("Could not access user ID to compute allOthersLeft");
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
userIds.size === 1 && userIds.has(this.userId) && leftUserIds.size > 0
|
||||
endWith("decline" as const),
|
||||
);
|
||||
}),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe(
|
||||
distinctUntilChanged(),
|
||||
filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false),
|
||||
/**
|
||||
* Whether some Matrix user other than ourself is joined to the call.
|
||||
*/
|
||||
private readonly someoneElseJoined$ = this.memberships$.pipe(
|
||||
map((ms) => ms.some((m) => m.sender !== this.userId)),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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$: 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),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1580,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=3a33c658bbcb8ce8791ec066db899f2571f5c52f"
|
||||
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/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1
|
||||
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