From 3924429689d7daefaa3f24f1dd0bcc8d99685a53 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 16 Dec 2024 15:27:51 +0000 Subject: [PATCH] Tests all pass. --- src/button/ReactionToggleButton.test.tsx | 14 +- src/button/ReactionToggleButton.tsx | 4 +- src/reactions/useReactionsReader.ts | 27 ++-- src/{ => reactions}/useReactionsSender.tsx | 9 +- src/room/CallEventAudioRenderer.tsx | 2 +- src/room/GroupCallView.test.tsx | 2 +- src/room/InCallView.tsx | 2 +- src/room/ReactionAudioRenderer.test.tsx | 2 +- src/room/ReactionAudioRenderer.tsx | 6 +- src/room/ReactionsOverlay.test.tsx | 48 +++--- src/room/ReactionsOverlay.tsx | 4 +- src/state/CallViewModel.test.ts | 170 ++++++++------------ src/state/CallViewModel.ts | 20 +-- src/state/MediaViewModel.ts | 14 +- src/tile/GridTile.test.tsx | 11 +- src/tile/GridTile.tsx | 6 +- src/useReactions.test.tsx | 171 --------------------- src/utils/test-fixtures.ts | 7 + src/utils/test-viewmodel.ts | 18 ++- src/utils/test.ts | 2 +- src/utils/testReactions.tsx | 1 + 21 files changed, 177 insertions(+), 363 deletions(-) rename src/{ => reactions}/useReactionsSender.tsx (95%) delete mode 100644 src/useReactions.test.tsx diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index 6d881bb3..a2e74f56 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -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({}); }); diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index dd5b6a7d..c0024378 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -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; diff --git a/src/reactions/useReactionsReader.ts b/src/reactions/useReactionsReader.ts index 6e25fd76..cfffe5ec 100644 --- a/src/reactions/useReactionsReader.ts +++ b/src/reactions/useReactionsReader.ts @@ -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, diff --git a/src/useReactionsSender.tsx b/src/reactions/useReactionsSender.tsx similarity index 95% rename from src/useReactionsSender.tsx rename to src/reactions/useReactionsSender.tsx index 6b1eaaac..248b910d 100644 --- a/src/useReactionsSender.tsx +++ b/src/reactions/useReactionsSender.tsx @@ -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; diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index 1a191109..6569ee47 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -76,7 +76,7 @@ export function CallEventAudioRenderer({ }); const handRaisedSub = vm.newHandRaised.subscribe(() => { - audioEngineRef.current?.playSound("raiseHand"); + void audioEngineRef.current?.playSound("raiseHand"); }); return (): void => { diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 41e587a7..b1cb53f0 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -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"); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 9d5fb3af..73e8e1b1 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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"; diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index 5bf08b67..e7ea018c 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -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, diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index fe30a315..024d8be1 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -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"); } } }); diff --git a/src/room/ReactionsOverlay.test.tsx b/src/room/ReactionsOverlay.test.tsx index 04d647fd..d0510744 100644 --- a/src/room/ReactionsOverlay.test.tsx +++ b/src/room/ReactionsOverlay.test.tsx @@ -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(); 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(); 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(); 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(); act(() => { - vm.updateReactions({ - reactions: { [aliceRtcMember.deviceId]: reaction }, - raisedHands: {}, + reactionsSubject.next({ + [aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 }, }); }); expect(container.getElementsByTagName("span")).toHaveLength(0); diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 97031c5b..c41219d2 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -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); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 93daba12..a869b609 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -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[]>, connectionState: Observable, speaking: Map>, - continuation: (vm: CallViewModel) => void, + continuation: ( + vm: CallViewModel, + subjects: { raisedHands: BehaviorSubject> }, + ) => void, ): void { const room = mockMatrixRoom({ client: { @@ -235,6 +239,8 @@ function withCallViewModel( { remoteParticipants }, ); + const raisedHands = new BehaviorSubject>({}); + 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", - ], - }, - }, - ); - }, - ); - }); - }, -); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index e067331e..91435346 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -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, - reactions: Observable, + handRaised: Observable, + reactions: Observable, ) { 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 = diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 78a6624d..4910afea 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -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, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, - public readonly handRaised: Observable, - public readonly reactions: Observable, + public readonly handRaised: Observable, + public readonly reactions: Observable, ) { super( id, @@ -440,8 +440,8 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { participant: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, - handRaised: Observable, - reactions: Observable, + handRaised: Observable, + reactions: Observable, ) { super( id, @@ -511,8 +511,8 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { participant: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, - handRaised: Observable, - reactions: Observable, + handRaised: Observable, + reactions: Observable, ) { super( id, diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index c5fa1183..c02ce307 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -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 as CallViewModel; const { container } = render( - + {}} @@ -60,7 +65,7 @@ test("GridTile is accessible", async () => { targetHeight={200} showSpeakingIndicators /> - , + , ); expect(await axe(container)).toHaveNoViolations(); // Name should be visible diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 3e41646e..3b76b87c 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -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( {menu} } - raisedHandTime={handRaised} - currentReaction={reaction} + raisedHandTime={handRaised ?? undefined} + currentReaction={reaction ?? undefined} raisedHandOnClick={raisedHandOnClick} localParticipant={vm.local} {...props} diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx deleted file mode 100644 index 76764fbb..00000000 --- a/src/useReactions.test.tsx +++ /dev/null @@ -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 = { - [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 ( -
-
    - {Object.entries(raisedHands).map(([userId, date]) => ( -
  • - {userId} - -
  • - ))} -
-
- ); -}; - -describe("useReactions", () => { - test("starts with an empty list", () => { - const rtcSession = new ReactionsMockRTCSession( - new MockRoom(memberUserIdAlice), - membership, - ); - const { queryByRole } = render( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob)); - expect(queryByRole("list")?.children).to.have.lengthOf(0); - }); -}); diff --git a/src/utils/test-fixtures.ts b/src/utils/test-fixtures.ts index 630f51a3..a105b5f7 100644 --- a/src/utils/test-fixtures.ts +++ b/src/utils/test-fixtures.ts @@ -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, diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 1b8b4e4a..c1a9f13f 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -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. diff --git a/src/utils/test.ts b/src/utils/test.ts index a7d69ea4..56053964 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -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(); diff --git a/src/utils/testReactions.tsx b/src/utils/testReactions.tsx index 56e9fa86..00f4ad52 100644 --- a/src/utils/testReactions.tsx +++ b/src/utils/testReactions.tsx @@ -15,6 +15,7 @@ import { EventTimelineSet, type Room, } from "matrix-js-sdk/src/matrix"; + import { type ECallReactionEventContent, ElementCallReactionEventType,