Tests all pass.

This commit is contained in:
Half-Shot
2024-12-16 15:27:51 +00:00
parent 22ea31dfd8
commit 3924429689
21 changed files with 177 additions and 363 deletions

View File

@@ -10,15 +10,15 @@ import { expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { type ReactNode } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions";
import { CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../useReactionsSender";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
@@ -71,7 +71,7 @@ test("Can raise hand", async () => {
},
},
);
await act(() => {
act(() => {
// Mock receiving a reaction.
handRaisedSubject.next({
[localIdent]: {
@@ -94,7 +94,7 @@ test("Can lower hand", async () => {
);
await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand"));
await act(() => {
act(() => {
handRaisedSubject.next({
[localIdent]: {
time: new Date(),
@@ -109,7 +109,7 @@ test("Can lower hand", async () => {
rtcSession.room.roomId,
reactionEventId,
);
await act(() => {
act(() => {
// Mock receiving a redacted reaction.
handRaisedSubject.next({});
});

View File

@@ -27,7 +27,7 @@ import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
import { useReactionsSender } from "../useReactionsSender";
import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css";
import {
type ReactionOption,
@@ -35,7 +35,7 @@ import {
ReactionsRowSize,
} from "../reactions";
import { Modal } from "../Modal";
import { CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;

View File

@@ -1,24 +1,32 @@
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { useCallback, useEffect, useRef } from "react";
import { useLatest } from "../useLatest";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
import {
RelationType,
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
import { BehaviorSubject, type Observable } from "rxjs";
import {
ElementCallReactionEventType,
ECallReactionEventContent,
type ECallReactionEventContent,
GenericReaction,
ReactionSet,
RaisedHandInfo,
ReactionInfo,
type RaisedHandInfo,
type ReactionInfo,
} from ".";
import { BehaviorSubject, Observable } from "rxjs";
import { useLatest } from "../useLatest";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
const REACTION_ACTIVE_TIME_MS = 3000;
@@ -256,6 +264,7 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): {
// may still be sending.
room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
const innerReactionsSubject = reactionsSubject.current;
return (): void => {
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
@@ -263,7 +272,7 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): {
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
reactionTimeouts.forEach((t) => clearTimeout(t));
// If we're clearing timeouts, we also clear all reactions.
reactionsSubject.current.next({});
innerReactionsSubject.next({});
};
}, [
room,

View File

@@ -15,13 +15,14 @@ import {
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "./reactions";
import { CallViewModel } from "./state/CallViewModel";
import { useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel";
interface ReactionsSenderContextType {
supportsReactions: boolean;
toggleRaisedHand: () => Promise<void>;

View File

@@ -76,7 +76,7 @@ export function CallEventAudioRenderer({
});
const handRaisedSub = vm.newHandRaised.subscribe(() => {
audioEngineRef.current?.playSound("raiseHand");
void audioEngineRef.current?.playSound("raiseHand");
});
return (): void => {

View File

@@ -14,6 +14,7 @@ import { JoinRule, type RoomState } from "matrix-js-sdk/src/matrix";
import { Router } from "react-router-dom";
import { createBrowserHistory } from "history";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { type MuteStates } from "./MuteStates";
import { prefetchSounds } from "../soundUtils";
@@ -29,7 +30,6 @@ import { GroupCallView } from "./GroupCallView";
import { leaveRTCSession } from "../rtcSessionHelpers";
import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
vitest.mock("../soundUtils");
vitest.mock("../useAudioContext");

View File

@@ -86,7 +86,7 @@ import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
import {
ReactionsSenderProvider,
useReactionsSender,
} from "../useReactionsSender";
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { ReactionsOverlay } from "./ReactionsOverlay";

View File

@@ -27,7 +27,7 @@ import {
import { useAudioContext } from "../useAudioContext";
import { GenericReaction, ReactionSet } from "../reactions";
import { prefetchSounds } from "../soundUtils";
import { CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import {
alice,

View File

@@ -12,7 +12,7 @@ import { GenericReaction, ReactionSet } from "../reactions";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
import { CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel";
const soundMap = Object.fromEntries([
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
@@ -51,10 +51,10 @@ export function ReactionsAudioRenderer({
const sub = vm.audibleReactions.subscribe((newReactions) => {
for (const reactionName of newReactions) {
if (soundMap[reactionName]) {
audioEngineRef.current?.playSound(reactionName);
void audioEngineRef.current?.playSound(reactionName);
} else {
// Fallback sounds.
audioEngineRef.current?.playSound("generic");
void audioEngineRef.current?.playSound("generic");
}
}
});

View File

@@ -34,13 +34,15 @@ test("defaults to showing no reactions", () => {
test("shows a reaction when sent", () => {
showReactions.setValue(true);
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, reactionsSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
const { getByRole } = render(<ReactionsOverlay vm={vm} />);
const reaction = ReactionSet[0];
act(() => {
vm.updateReactions({
reactions: { [aliceRtcMember.deviceId]: reaction },
raisedHands: {},
reactionsSubject.next({
[aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 },
});
});
const span = getByRole("presentation");
@@ -51,15 +53,15 @@ test("shows a reaction when sent", () => {
test("shows two of the same reaction when sent", () => {
showReactions.setValue(true);
const reaction = ReactionSet[0];
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, reactionsSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
act(() => {
vm.updateReactions({
reactions: {
[aliceRtcMember.deviceId]: reaction,
[bobRtcMember.deviceId]: reaction,
},
raisedHands: {},
reactionsSubject.next({
[aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 },
[bobRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 },
});
});
expect(getAllByRole("presentation")).toHaveLength(2);
@@ -68,15 +70,15 @@ test("shows two of the same reaction when sent", () => {
test("shows two different reactions when sent", () => {
showReactions.setValue(true);
const [reactionA, reactionB] = ReactionSet;
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, reactionsSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
act(() => {
vm.updateReactions({
reactions: {
[aliceRtcMember.deviceId]: reactionA,
[bobRtcMember.deviceId]: reactionB,
},
raisedHands: {},
reactionsSubject.next({
[aliceRtcMember.deviceId]: { reactionOption: reactionA, ttl: 0 },
[bobRtcMember.deviceId]: { reactionOption: reactionB, ttl: 0 },
});
});
const [reactionElementA, reactionElementB] = getAllByRole("presentation");
@@ -87,12 +89,14 @@ test("shows two different reactions when sent", () => {
test("hides reactions when reaction animations are disabled", () => {
showReactions.setValue(false);
const reaction = ReactionSet[0];
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, reactionsSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
const { container } = render(<ReactionsOverlay vm={vm} />);
act(() => {
vm.updateReactions({
reactions: { [aliceRtcMember.deviceId]: reaction },
raisedHands: {},
reactionsSubject.next({
[aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 },
});
});
expect(container.getElementsByTagName("span")).toHaveLength(0);

View File

@@ -6,10 +6,10 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode } from "react";
import { useObservableState } from "observable-hooks";
import styles from "./ReactionsOverlay.module.css";
import { CallViewModel } from "../state/CallViewModel";
import { useObservableState } from "observable-hooks";
import { type CallViewModel } from "../state/CallViewModel";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
const reactionsIcons = useObservableState(vm.visibleReactions);

View File

@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { test, vi, onTestFinished, it, vitest } from "vitest";
import { test, vi, onTestFinished, it } from "vitest";
import {
BehaviorSubject,
combineLatest,
debounceTime,
distinctUntilChanged,
@@ -14,7 +15,6 @@ import {
type Observable,
of,
switchMap,
tap,
} from "rxjs";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import {
@@ -47,6 +47,7 @@ import {
type ECConnectionState,
} from "../livekit/useECConnectionState";
import { E2eeType } from "../e2ee/e2eeType";
import { RaisedHandInfo } from "../reactions";
vi.mock("@livekit/components-core");
@@ -190,7 +191,10 @@ function withCallViewModel(
rtcMembers: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void,
continuation: (
vm: CallViewModel,
subjects: { raisedHands: BehaviorSubject<Record<string, RaisedHandInfo>> },
) => void,
): void {
const room = mockMatrixRoom({
client: {
@@ -235,6 +239,8 @@ function withCallViewModel(
{ remoteParticipants },
);
const raisedHands = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
@@ -242,6 +248,8 @@ function withCallViewModel(
kind: E2eeType.PER_PARTICIPANT,
},
connectionState,
raisedHands,
new BehaviorSubject({}),
);
onTestFinished(() => {
@@ -252,7 +260,7 @@ function withCallViewModel(
roomEventSelectorSpy!.mockRestore();
});
continuation(vm);
continuation(vm, { raisedHands });
}
test("participants are retained during a focus switch", () => {
@@ -689,117 +697,63 @@ it("should show at least one tile per MatrixRTCSession", () => {
});
});
it("Should correctly handle a raised hand event", () => {
withTestScheduler(({ expectObservable }) => {
const date = new Date();
it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "ab";
withCallViewModel(
of([]),
of([]),
of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.addHandRaised(alice.userId, date);
expectObservable(vm.handsRaised).toBe("a", {
a: {
[alice.userId]: date,
(vm, { raisedHands }) => {
schedule("ab", {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i <= 1);
}
});
},
b: () => {
raisedHands.next({
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]: {
time: new Date(),
reactionEventId: "",
membershipEventId: "",
},
});
},
});
expectObservable(vm.handRaised).toBe("a", {
a: {
value: 1,
playSounds: true,
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@alice:example.org:AAAA:0",
"@bob:example.org:BBBB:0",
],
},
b: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
// Bob shifts up!
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
},
});
);
},
);
});
});
it.only("Should correctly handle a lowered hand event", () => {
const date = new Date();
withTestScheduler(({ expectObservable }) => {
withCallViewModel(
of([]),
of([]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.addHandRaised(alice.userId, date);
vm.addHandRaised(bob.userId, date);
expectObservable(vm.handsRaised).toBe("a", {
a: {
[bob.userId]: date,
},
b: {
[bob.userId]: date,
},
});
},
);
});
});
// TODO: Add presenters and speakers?
it.todo(
"should rank raised hands above video feeds and below speakers and presenters",
() => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "a";
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule("ah", {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
console.log(layout);
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i <= 1);
}
});
},
h: () => {
vm.updateReactions({
reactions: {},
raisedHands: {
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]:
new Date(),
},
});
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
h: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
},
);
},
);
});
},
);

View File

@@ -45,7 +45,6 @@ import {
switchMap,
switchScan,
take,
tap,
timer,
withLatestFrom,
} from "rxjs";
@@ -88,7 +87,11 @@ import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker";
import { RaisedHandInfo, ReactionInfo, ReactionOption } from "../reactions";
import {
type RaisedHandInfo,
type ReactionInfo,
type ReactionOption,
} from "../reactions";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -251,8 +254,8 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
handRaised: Observable<Date | null>,
reactions: Observable<ReactionOption | null>,
) {
this.participant = new BehaviorSubject(participant);
@@ -528,7 +531,7 @@ export class CallViewModel extends ViewModel {
this.encryptionSystem,
this.livekitRoom,
this.handsRaised.pipe(
map((v) => v[matrixIdentifier].time ?? undefined),
map((v) => v[matrixIdentifier]?.time ?? null),
),
this.reactions.pipe(
map((v) => v[matrixIdentifier] ?? undefined),
@@ -649,7 +652,6 @@ export class CallViewModel extends ViewModel {
m.vm.handRaised,
],
(speaker, presenter, video, alwaysShow, handRaised) => {
console.log(m.vm.id, handRaised);
let bin: SortingBin;
if (m.vm.local)
bin = alwaysShow
@@ -672,12 +674,6 @@ export class CallViewModel extends ViewModel {
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
);
}),
tap((v) =>
console.log(
"final grid",
v.map((v) => v.id),
),
),
);
private readonly spotlight: Observable<MediaViewModel[]> =

View File

@@ -51,7 +51,7 @@ import { alwaysShowSelf } from "../settings/settings";
import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { ReactionOption } from "../reactions";
import { type ReactionOption } from "../reactions";
// TODO: Move this naming logic into the view model
export function useDisplayName(vm: MediaViewModel): string {
@@ -372,8 +372,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
public readonly handRaised: Observable<Date | undefined>,
public readonly reactions: Observable<ReactionOption | undefined>,
public readonly handRaised: Observable<Date | null>,
public readonly reactions: Observable<ReactionOption | null>,
) {
super(
id,
@@ -440,8 +440,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
handRaised: Observable<Date | null>,
reactions: Observable<ReactionOption | null>,
) {
super(
id,
@@ -511,8 +511,8 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
participant: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
handRaised: Observable<Date | undefined>,
reactions: Observable<ReactionOption | undefined>,
handRaised: Observable<Date | null>,
reactions: Observable<ReactionOption | null>,
) {
super(
id,

View File

@@ -15,7 +15,8 @@ import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSess
import { GridTile } from "./GridTile";
import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsProvider } from "../useReactionsSender";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { CallViewModel } from "../state/CallViewModel";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
@@ -51,8 +52,12 @@ test("GridTile is accessible", async () => {
},
memberships: [],
} as unknown as MatrixRTCSession;
const cVm = {
reactions: of({}),
handsRaised: of({}),
} as Partial<CallViewModel> as CallViewModel;
const { container } = render(
<ReactionsProvider rtcSession={fakeRtcSession}>
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
<GridTile
vm={new GridTileViewModel(of(vm))}
onOpenProfile={() => {}}
@@ -60,7 +65,7 @@ test("GridTile is accessible", async () => {
targetHeight={200}
showSpeakingIndicators
/>
</ReactionsProvider>,
</ReactionsSenderProvider>,
);
expect(await axe(container)).toHaveNoViolations();
// Name should be visible

View File

@@ -48,7 +48,7 @@ import { MediaView } from "./MediaView";
import { useLatest } from "../useLatest";
import { type GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../useReactionsSender";
import { useReactionsSender } from "../reactions/useReactionsSender";
interface TileProps {
className?: string;
@@ -170,8 +170,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
{menu}
</Menu>
}
raisedHandTime={handRaised}
currentReaction={reaction}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
{...props}

View File

@@ -1,171 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { act, type FC } from "react";
import { describe, expect, test } from "vitest";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { useReactionsSender } from "./useReactionsSender";
import {
createHandRaisedReaction,
createRedaction,
MockRoom,
} from "./utils/testReactions";
const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org";
const memberUserIdBob = "@bob:example.org";
const memberEventBob = "$membership-bob:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob,
"$membership-charlie:example.org": "@charlie:example.org",
};
/**
* Test explanation.
* This test suite checks that the useReactions hook appropriately reacts
* to new reactions, redactions and membership changesin the room. There is
* a large amount of test structure used to construct a mock environment.
*/
const TestComponent: FC = () => {
const { raisedHands } = useReactionsSender();
return (
<div>
<ul>
{Object.entries(raisedHands).map(([userId, date]) => (
<li key={userId}>
<span>{userId}</span>
<time>{date.getTime()}</time>
</li>
))}
</ul>
</div>
);
};
describe("useReactions", () => {
test("starts with an empty list", () => {
const rtcSession = new ReactionsMockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles incoming raised hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
await act(() => room.testSendHandRaise(memberEventAlice, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(1);
await act(() => room.testSendHandRaise(memberEventBob, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
test("handles incoming unraised hand", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
const reactionEventId = await act(() =>
room.testSendHandRaise(memberEventAlice, membership),
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
await act(() =>
room.emit(
RoomEvent.Redaction,
createRedaction(memberUserIdAlice, reactionEventId),
room,
undefined,
),
);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles loading prior raised hand events", () => {
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
});
// If the membership event changes for a user, we want to remove
// the raised hand event.
test("will remove reaction when a member leaves the call", () => {
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
act(() => rtcSession.testRemoveMember(memberUserIdAlice));
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("will remove reaction when a member joins via a new event", () => {
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
// Simulate leaving and rejoining
act(() => {
rtcSession.testRemoveMember(memberUserIdAlice);
rtcSession.testAddMember(memberUserIdAlice);
});
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("ignores invalid sender for historic event", () => {
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, memberUserIdBob),
]);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("ignores invalid sender for new event", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob));
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
});

View File

@@ -1,3 +1,10 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
mockRtcMembership,
mockMatrixRoomMember,

View File

@@ -1,9 +1,18 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ConnectionState } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk/src/client";
import { type RoomMember } from "matrix-js-sdk/src/matrix";
import { type CallMembership, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { BehaviorSubject, of } from "rxjs";
import { vitest } from "vitest";
import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import { mockLivekitRoom, mockMatrixRoom, MockRTCSession } from "./test";
@@ -13,8 +22,7 @@ import {
localParticipant,
localRtcMember,
} from "./test-fixtures";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { RaisedHandInfo, ReactionInfo } from "../reactions";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
/**
* Construct a basic CallViewModel to test components that make use of it.

View File

@@ -28,6 +28,7 @@ import {
type RemoteTrackPublication,
type Room as LivekitRoom,
} from "livekit-client";
import { randomUUID } from "crypto";
import {
LocalUserMediaViewModel,
@@ -39,7 +40,6 @@ import {
type ResolvedConfigOptions,
} from "../config/ConfigOptions";
import { Config } from "../config/Config";
import { randomUUID } from "crypto";
export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers();

View File

@@ -15,6 +15,7 @@ import {
EventTimelineSet,
type Room,
} from "matrix-js-sdk/src/matrix";
import {
type ECallReactionEventContent,
ElementCallReactionEventType,