diff --git a/src/reactions/index.ts b/src/reactions/index.ts index 53b26f20..f20b9340 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -198,10 +198,6 @@ export interface RaisedHandInfo { } export interface ReactionInfo { - /** - * ... - */ - ttl: number; - + expireAfter: Date; reactionOption: ReactionOption; } diff --git a/src/reactions/useReactionsReader.test.tsx b/src/reactions/useReactionsReader.test.tsx new file mode 100644 index 00000000..4838b257 --- /dev/null +++ b/src/reactions/useReactionsReader.test.tsx @@ -0,0 +1,512 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { renderHook } from "@testing-library/react"; +import { afterEach, test, vitest } from "vitest"; +import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; +import { + RoomEvent as MatrixRoomEvent, + MatrixEvent, + type IRoomTimelineData, + EventType, + MatrixEventEvent, +} from "matrix-js-sdk/src/matrix"; + +import useReactionsReader, { + REACTION_ACTIVE_TIME_MS, +} from "./useReactionsReader"; +import { + alice, + aliceRtcMember, + local, + localRtcMember, +} from "../utils/test-fixtures"; +import { getBasicRTCSession } from "../utils/test-viewmodel"; +import { withTestScheduler } from "../utils/test"; +import { ElementCallReactionEventType, ReactionSet } from "."; + +afterEach(() => { + vitest.useRealTimers(); +}); + +test("handles a hand raised reaction", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const localTimestamp = new Date(); + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { raisedHands$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("ab", { + a: () => {}, + b: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.Reaction, + origin_server_ts: localTimestamp.getTime(), + content: { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(raisedHands$).toBe("ab", { + a: {}, + b: { + [`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { + reactionEventId, + membershipEventId: localRtcMember.eventId, + time: localTimestamp, + }, + }, + }); + }); + }); +}); + +test("handles a redaction", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const localTimestamp = new Date(); + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { raisedHands$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("abc", { + a: () => {}, + b: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.Reaction, + origin_server_ts: localTimestamp.getTime(), + content: { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + c: () => { + rtcSession.room.emit( + MatrixRoomEvent.Redaction, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.RoomRedaction, + redacts: reactionEventId, + }), + rtcSession.room, + undefined, + ); + }, + }); + expectObservable(raisedHands$).toBe("abc", { + a: {}, + b: { + [`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { + reactionEventId, + membershipEventId: localRtcMember.eventId, + time: localTimestamp, + }, + }, + c: {}, + }); + }); + }); +}); + +test("handles waiting for event decryption", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const localTimestamp = new Date(); + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { raisedHands$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("abc", { + a: () => {}, + b: () => { + const encryptedEvent = new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.Reaction, + origin_server_ts: localTimestamp.getTime(), + content: { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + }, + }, + }); + // Should ignore encrypted events that are still encrypting + encryptedEvent["decryptionPromise"] = Promise.resolve(); + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + encryptedEvent, + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + c: () => { + rtcSession.room.client.emit( + MatrixEventEvent.Decrypted, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.Reaction, + origin_server_ts: localTimestamp.getTime(), + content: { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + }, + }, + }), + ); + }, + }); + expectObservable(raisedHands$).toBe("a-c", { + a: {}, + c: { + [`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { + reactionEventId, + membershipEventId: localRtcMember.eventId, + time: localTimestamp, + }, + }, + }); + }); + }); +}); + +test("hands rejecting events without a proper membership", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const localTimestamp = new Date(); + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { raisedHands$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("ab", { + a: () => {}, + b: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: EventType.Reaction, + origin_server_ts: localTimestamp.getTime(), + content: { + "m.relates_to": { + event_id: "$not-this-one:example.org", + key: "🖐️", + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(raisedHands$).toBe("a-", { + a: {}, + }); + }); + }); +}); + +test("handles a reaction", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const reaction = ReactionSet[1]; + + vitest.setSystemTime(0); + + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { reactions$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("ab", { + a: () => {}, + b: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: reaction.emoji, + name: reaction.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + c: () => { + vitest.advanceTimersByTime(5000); + }, + }); + expectObservable(reactions$).toBe( + `ab ${REACTION_ACTIVE_TIME_MS - 1}ms c`, + { + a: {}, + b: { + [`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { + reactionOption: reaction, + expireAfter: new Date(REACTION_ACTIVE_TIME_MS), + }, + }, + // Expect reaction to expire. + c: {}, + }, + ); + }); + }); +}); + +test("ignores bad reaction events", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const reaction = ReactionSet[1]; + + vitest.setSystemTime(0); + + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { reactions$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("ab", { + a: () => {}, + b: () => { + // Missing content + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: {}, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + // Wrong relates event + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: reaction.emoji, + name: reaction.name, + "m.relates_to": { + event_id: "wrong-event", + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + // Wrong rtc member event + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: aliceRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: reaction.emoji, + name: reaction.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + // No emoji + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + name: reaction.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + // Invalid emoji + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: " ", + name: reaction.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(reactions$).toBe("a-", { + a: {}, + }); + }); + }); +}); + +test("that reactions cannot be spammed", () => { + const { rtcSession } = getBasicRTCSession([local, alice]); + const reactionEventId = "$my_event_id:example.org"; + const reactionA = ReactionSet[1]; + const reactionB = ReactionSet[2]; + + vitest.setSystemTime(0); + + withTestScheduler(({ schedule, expectObservable }) => { + renderHook(() => { + const { reactions$ } = useReactionsReader( + rtcSession as unknown as MatrixRTCSession, + ); + schedule("abc", { + a: () => {}, + b: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: reactionA.emoji, + name: reactionA.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + c: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ + room_id: rtcSession.room.roomId, + event_id: reactionEventId, + sender: localRtcMember.sender, + type: ElementCallReactionEventType, + content: { + emoji: reactionB.emoji, + name: reactionB.name, + "m.relates_to": { + event_id: localRtcMember.eventId, + }, + }, + }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(reactions$).toBe( + `ab ${REACTION_ACTIVE_TIME_MS - 1}ms c`, + { + a: {}, + b: { + [`${localRtcMember.sender}:${localRtcMember.deviceId}`]: { + reactionOption: reactionA, + expireAfter: new Date(REACTION_ACTIVE_TIME_MS), + }, + }, + c: {}, + }, + ); + }); + }); +}); diff --git a/src/reactions/useReactionsReader.ts b/src/reactions/useReactionsReader.ts index 1aa18001..70a3deb5 100644 --- a/src/reactions/useReactionsReader.ts +++ b/src/reactions/useReactionsReader.ts @@ -15,7 +15,7 @@ import { EventType, RoomEvent as MatrixRoomEvent, } from "matrix-js-sdk/src/matrix"; -import { BehaviorSubject, type Observable } from "rxjs"; +import { BehaviorSubject, delay, type Observable } from "rxjs"; import { ElementCallReactionEventType, @@ -28,7 +28,7 @@ import { import { useLatest } from "../useLatest"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; -const REACTION_ACTIVE_TIME_MS = 3000; +export const REACTION_ACTIVE_TIME_MS = 3000; /** * Listens for reactions from a RTCSession and populates subjects @@ -46,6 +46,19 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): { new BehaviorSubject>({}), ); + reactionsSubject$.current + .pipe(delay(REACTION_ACTIVE_TIME_MS)) + .subscribe((reactions) => { + const date = new Date(); + const nextEntries = Object.fromEntries( + Object.entries(reactions).filter(([_, hr]) => hr.expireAfter < date), + ); + if (Object.keys(reactions).length === Object.keys(nextEntries).length) { + return; + } + reactionsSubject$.current.next(nextEntries); + }); + const memberships = useMatrixRTCSessionMemberships(rtcSession); const latestMemberships = useLatest(memberships); const latestRaisedHands = useLatest(raisedHandsSubject$.current); @@ -132,7 +145,6 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): { // This effect handles any *live* reaction/redactions in the room. useEffect(() => { - const reactionTimeouts = new Set(); const handleReactionEvent = (event: MatrixEvent): void => { // Decrypted events might come from a different room if (event.getRoomId() !== room.roomId) return; @@ -178,7 +190,7 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): { [Symbol.iterator](); const emoji = segment.next().value?.segment; - if (!emoji) { + if (!emoji?.trim()) { logger.warn( `Reaction had no emoji from ${reactionEventId} after splitting`, ); @@ -198,23 +210,11 @@ export default function useReactionsReader(rtcSession: MatrixRTCSession): { // We've still got a reaction from this user, ignore it to prevent spamming return; } - const timeout = globalThis.setTimeout(() => { - // Clear the reaction after some time. - reactionsSubject$.current.next( - Object.fromEntries( - Object.entries(reactionsSubject$.current.value).filter( - ([id]) => id !== identifier, - ), - ), - ); - reactionTimeouts.delete(timeout); - }, REACTION_ACTIVE_TIME_MS); - reactionTimeouts.add(timeout); reactionsSubject$.current.next({ ...currentReactions, [identifier]: { reactionOption: reaction, - ttl: 0, + expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS), }, }); } else if (event.getType() === EventType.Reaction) { @@ -264,15 +264,11 @@ 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); room.client.off(MatrixEventEvent.Decrypted, handleReactionEvent); room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); - reactionTimeouts.forEach((t) => clearTimeout(t)); - // If we're clearing timeouts, we also clear all reactions. - innerReactionsSubject$.next({}); }; }, [ room, diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index 9ba198f9..e8a41d7b 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -96,7 +96,10 @@ test("will play an audio sound when there is a reaction", () => { } act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: chosenReaction, ttl: 0 }, + [aliceRtcMember.deviceId]: { + reactionOption: chosenReaction, + expireAfter: 0, + }, }); }); expect(playSound).toHaveBeenCalledWith(chosenReaction.name); @@ -119,7 +122,10 @@ test("will play the generic audio sound when there is soundless reaction", () => } act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: chosenReaction, ttl: 0 }, + [aliceRtcMember.deviceId]: { + reactionOption: chosenReaction, + expireAfter: 0, + }, }); }); expect(playSound).toHaveBeenCalledWith(GenericReaction.name); @@ -142,9 +148,9 @@ test("will play multiple audio sounds when there are multiple different reaction } act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: reaction1, ttl: 0 }, - [bobRtcMember.deviceId]: { reactionOption: reaction2, ttl: 0 }, - [localRtcMember.deviceId]: { reactionOption: reaction1, ttl: 0 }, + [aliceRtcMember.deviceId]: { reactionOption: reaction1, expireAfter: 0 }, + [bobRtcMember.deviceId]: { reactionOption: reaction2, expireAfter: 0 }, + [localRtcMember.deviceId]: { reactionOption: reaction1, expireAfter: 0 }, }); }); expect(playSound).toHaveBeenCalledWith(reaction1.name); diff --git a/src/room/ReactionsOverlay.test.tsx b/src/room/ReactionsOverlay.test.tsx index 37660e2c..f1401132 100644 --- a/src/room/ReactionsOverlay.test.tsx +++ b/src/room/ReactionsOverlay.test.tsx @@ -42,7 +42,7 @@ test("shows a reaction when sent", () => { const reaction = ReactionSet[0]; act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 }, + [aliceRtcMember.deviceId]: { reactionOption: reaction, expireAfter: 0 }, }); }); const span = getByRole("presentation"); @@ -60,8 +60,8 @@ test("shows two of the same reaction when sent", () => { const { getAllByRole } = render(); act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 }, - [bobRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 }, + [aliceRtcMember.deviceId]: { reactionOption: reaction, expireAfter: 0 }, + [bobRtcMember.deviceId]: { reactionOption: reaction, expireAfter: 0 }, }); }); expect(getAllByRole("presentation")).toHaveLength(2); @@ -77,8 +77,8 @@ test("shows two different reactions when sent", () => { const { getAllByRole } = render(); act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: reactionA, ttl: 0 }, - [bobRtcMember.deviceId]: { reactionOption: reactionB, ttl: 0 }, + [aliceRtcMember.deviceId]: { reactionOption: reactionA, expireAfter: 0 }, + [bobRtcMember.deviceId]: { reactionOption: reactionB, expireAfter: 0 }, }); }); const [reactionElementA, reactionElementB] = getAllByRole("presentation"); @@ -96,7 +96,7 @@ test("hides reactions when reaction animations are disabled", () => { const { container } = render(); act(() => { reactionsSubject$.next({ - [aliceRtcMember.deviceId]: { reactionOption: reaction, ttl: 0 }, + [aliceRtcMember.deviceId]: { reactionOption: reaction, expireAfter: 0 }, }); }); expect(container.getElementsByTagName("span")).toHaveLength(0); diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 5b53419e..799ea1a1 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -15,6 +15,7 @@ import { import { BehaviorSubject, of } from "rxjs"; import { vitest } from "vitest"; import { type RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; +import EventEmitter from "events"; import { E2eeType } from "../e2ee/e2eeType"; import { CallViewModel } from "../state/CallViewModel"; @@ -27,6 +28,80 @@ import { } from "./test-fixtures"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; +export function getBasicRTCSession( + members: RoomMember[], + initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember], +): { + rtcSession: MockRTCSession; + remoteRtcMemberships$: BehaviorSubject; +} { + const matrixRoomId = "!myRoomId:example.com"; + const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); + + const roomEmitter = new EventEmitter(); + const clientEmitter = new EventEmitter(); + const matrixRoom = mockMatrixRoom({ + relations: { + getChildEventsForEvent: vitest.fn(), + } as Partial as RelationsContainer, + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), + redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), + decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined), + on: vitest + .fn() + .mockImplementation( + (eventName: string, fn: (...args: unknown[]) => void) => { + clientEmitter.on(eventName, fn); + }, + ), + emit: (eventName: string, ...args: unknown[]) => + clientEmitter.emit(eventName, ...args), + off: vitest + .fn() + .mockImplementation( + (eventName: string, fn: (...args: unknown[]) => void) => { + clientEmitter.off(eventName, fn); + }, + ), + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + roomId: matrixRoomId, + on: vitest + .fn() + .mockImplementation( + (eventName: string, fn: (...args: unknown[]) => void) => { + roomEmitter.on(eventName, fn); + }, + ), + emit: (eventName: string, ...args: unknown[]) => + roomEmitter.emit(eventName, ...args), + off: vitest + .fn() + .mockImplementation( + (eventName: string, fn: (...args: unknown[]) => void) => { + roomEmitter.off(eventName, fn); + }, + ), + }); + + const remoteRtcMemberships$ = new BehaviorSubject( + initialRemoteRtcMemberships, + ); + + const rtcSession = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships(remoteRtcMemberships$); + + return { + rtcSession, + remoteRtcMemberships$, + }; +} + /** * Construct a basic CallViewModel to test components that make use of it. * @param members @@ -43,41 +118,18 @@ export function getBasicCallViewModelEnvironment( handRaisedSubject$: BehaviorSubject>; reactionsSubject$: BehaviorSubject>; } { - const matrixRoomId = "!myRoomId:example.com"; - const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); + const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession( + members, + initialRemoteRtcMemberships, + ); + const handRaisedSubject$ = new BehaviorSubject({}); + const reactionsSubject$ = new BehaviorSubject({}); + const remoteParticipants$ = of([aliceParticipant]); const liveKitRoom = mockLivekitRoom( { localParticipant }, { remoteParticipants$ }, ); - const matrixRoom = mockMatrixRoom({ - relations: { - getChildEventsForEvent: vitest.fn(), - } as Partial as RelationsContainer, - client: { - getUserId: () => localRtcMember.sender, - getDeviceId: () => localRtcMember.deviceId, - sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), - redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), - on: vitest.fn(), - off: vitest.fn(), - } as Partial as MatrixClient, - getMember: (userId) => matrixRoomMembers.get(userId) ?? null, - roomId: matrixRoomId, - }); - - const remoteRtcMemberships$ = new BehaviorSubject( - initialRemoteRtcMemberships, - ); - - const handRaisedSubject$ = new BehaviorSubject({}); - const reactionsSubject$ = new BehaviorSubject({}); - - const rtcSession = new MockRTCSession( - matrixRoom, - localRtcMember, - ).withMemberships(remoteRtcMemberships$); - const vm = new CallViewModel( rtcSession as unknown as MatrixRTCSession, liveKitRoom, @@ -90,7 +142,7 @@ export function getBasicCallViewModelEnvironment( ); return { vm, - remoteRtcMemberships$: remoteRtcMemberships$, + remoteRtcMemberships$, rtcSession, handRaisedSubject$: handRaisedSubject$, reactionsSubject$: reactionsSubject$,