Merge remote-tracking branch 'origin/livekit' into hs/compound-switch

This commit is contained in:
Half-Shot
2026-04-27 10:03:41 +01:00
58 changed files with 1059 additions and 363 deletions

View File

@@ -0,0 +1,162 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import { CallEndedTracker } from "./PosthogEvents";
import { mockConfig } from "../utils/test";
const defaultCounters = {
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
};
const defaultTotals = {
roomEventEncryptionKeysReceivedTotalAge: 500,
};
function createMockRtcSession(overrides?: {
counters?: Partial<typeof defaultCounters>;
totals?: Partial<typeof defaultTotals>;
}): MatrixRTCSession {
return {
statistics: {
counters: { ...defaultCounters, ...overrides?.counters },
totals: { ...defaultTotals, ...overrides?.totals },
},
} as unknown as MatrixRTCSession;
}
describe("CallEnded", () => {
beforeAll(() => {
mockConfig();
});
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
() => {},
);
});
afterAll(() => {
PosthogAnalytics.resetInstance();
});
it("warns if startTime is missing when track is called", () => {
const warnSpy = vi.spyOn(logger, "warn");
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mockSession);
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled();
});
it("tracks event with correct properties when startTime is set", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
eventName: "CallEnded",
callId: "test-call-id",
callParticipantsMax: 5,
callParticipantsOnLeave: 3,
callDuration: expect.closeTo(60, 1),
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
roomEventEncryptionKeysReceivedAverageAge: 100,
},
{ send_instantly: true },
);
});
it("tracks maxParticipantsCount correctly across multiple changes", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
callParticipantsMax: 7,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 0 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 0,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 4 },
totals: { roomEventEncryptionKeysReceivedTotalAge: 200 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 50,
}),
expect.anything(),
);
});
it("passes send_instantly option correctly", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
{ send_instantly: false },
);
});
});

View File

@@ -27,8 +27,8 @@ interface CallEnded extends IPosthogEvent {
}
export class CallEndedTracker {
private cache: { startTime: Date; maxParticipantsCount: number } = {
startTime: new Date(0),
private cache: { startTime?: Date; maxParticipantsCount: number } = {
startTime: undefined,
maxParticipantsCount: 0,
};
@@ -49,26 +49,32 @@ export class CallEndedTracker {
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
): void {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
},
{ send_instantly: sendInstantly },
);
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
},
{ send_instantly: sendInstantly },
);
} else {
logger.warn(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
}
}
}

View File

@@ -47,7 +47,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -73,7 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}

View File

@@ -29,7 +29,7 @@ interface WasmFileset {
// MediaPipe and depend on node_modules having this specific structure. It's
// easy to see this breaking if our dependencies changed and MediaPipe were
// no longer hoisted, or if we switched to another dependency loader such as
// Yarn PnP.
// yarn PnP.
// https://github.com/google-ai-edge/mediapipe/issues/5961
const wasmFileset: WasmFileset = {
wasmLoaderPath: new URL(

View File

@@ -330,7 +330,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="true"
aria-labelledby="_r_i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
@@ -354,7 +354,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="true"
aria-labelledby="_r_n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { type FC, useRef } from "react";
import { type FC, useRef, useState } from "react";
import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event";
@@ -17,6 +17,7 @@ import {
ReactionSet,
ReactionsRowSize,
} from "./reactions";
import { type Controls } from "./controls";
// Test Explanation:
// - The main objective is to test `useCallViewKeyboardShortcuts`.
@@ -27,6 +28,7 @@ interface TestComponentProps {
onButtonClick?: () => void;
sendReaction?: () => void;
toggleHandRaised?: () => void;
initialModalOpen?: boolean;
}
const TestComponent: FC<TestComponentProps> = ({
@@ -34,7 +36,9 @@ const TestComponent: FC<TestComponentProps> = ({
onButtonClick = (): void => {},
sendReaction = (reaction: ReactionOption): void => {},
toggleHandRaised = (): void => {},
initialModalOpen = false,
}) => {
const [modalOpen, setModalOpen] = useState(initialModalOpen);
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts(
ref,
@@ -47,6 +51,19 @@ const TestComponent: FC<TestComponentProps> = ({
return (
<div ref={ref}>
<Button onClick={onButtonClick}>TEST</Button>
{modalOpen && (
<dialog
open
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setModalOpen(false);
}
}}
>
<button>InModalButton</button>
</dialog>
)}
</div>
);
};
@@ -118,6 +135,27 @@ test("raised hand can be sent via keyboard presses", async () => {
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("raised hand cannot be sent via keyboard presses if modal open and focussed", async () => {
const user = userEvent.setup();
const toggleHandRaised = vi.fn();
const { getByRole } = render(
<TestComponent
toggleHandRaised={toggleHandRaised}
initialModalOpen={true}
/>,
);
getByRole("button", { name: "InModalButton" }).focus();
await user.keyboard("h");
expect(toggleHandRaised).not.toHaveBeenCalledOnce();
// once we press esc...
await user.keyboard("[Escape]");
// we can toggle the hand raise...
await user.keyboard("h");
expect(toggleHandRaised).toHaveBeenCalledOnce();
});
test("unmuting happens in place of the default action", async () => {
const user = userEvent.setup();
const defaultPrevented = vi.fn();
@@ -138,3 +176,35 @@ test("unmuting happens in place of the default action", async () => {
await user.keyboard("[Space]");
expect(defaultPrevented).toBeCalledWith(true);
});
test("escape button triggers the controls back action", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
// In the real application, we mostly just want the spacebar shortcut to avoid
// scrolling the page. But to test that here in JSDOM, we need some kind of
// container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job.
render(<TestComponent setAudioEnabled={() => {}} />);
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});
test("escape button does not trigger back if sth else is focused", async () => {
const user = userEvent.setup();
window.controls = { onBackButtonPressed: vi.fn() } as unknown as Controls;
const { getByRole } = render(<TestComponent initialModalOpen={true} />);
getByRole("button", { name: "InModalButton" }).focus();
// First Escape: the dialog's onKeyDown intercepts it and closes the modal.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).not.toHaveBeenCalled();
// Second Escape: modal is gone, focus has fallen back to document.body,
// which *does* contain the ref div, so the hook fires and back IS triggered.
await user.keyboard("[Escape]");
expect(window.controls.onBackButtonPressed).toHaveBeenCalled();
});

View File

@@ -68,6 +68,8 @@ export function useCallViewKeyboardShortcuts(
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") {
window.controls.onBackButtonPressed?.();
}
},
[