From 865187d58e0c459263b2fa8b02a29de241037c3b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 14:36:25 +0000 Subject: [PATCH 01/11] Add support for using CallViewModel for reactions sounds. --- src/room/GroupCallView.tsx | 27 +++++++++++++++------------ src/room/InCallView.tsx | 7 ++++++- src/room/ReactionsOverlay.tsx | 27 ++++++++++----------------- src/state/CallViewModel.ts | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 9336ffdd5..f0a74b8bc 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -41,6 +41,7 @@ import { InviteModal } from "./InviteModal"; import { useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { Link } from "../button/Link"; +import { ReactionsProvider } from "../useReactions"; declare global { interface Window { @@ -328,18 +329,20 @@ export const GroupCallView: FC = ({ return ( <> {shareModal} - + + + ); } else if (left && widget === null) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8710d3e8a..b13820471 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -107,6 +107,7 @@ export const ActiveCall: FC = (props) => { [connState], ); const [vm, setVm] = useState(null); + const reactions = useReactions(); useEffect(() => { return (): void => { @@ -117,6 +118,10 @@ export const ActiveCall: FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + vm?.updateReactions(reactions); + }, [vm, reactions]); + useEffect(() => { if (livekitRoom !== undefined) { const vm = new CallViewModel( @@ -638,7 +643,7 @@ export const InCallView: FC = ({ {renderContent()} - + {footer} {layout.type !== "pip" && ( <> diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 7cdf7568f..edc967eac 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -5,33 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { ReactNode, useMemo } from "react"; - -import { useReactions } from "../useReactions"; +import { ReactNode } from "react"; import { showReactions as showReactionsSetting, useSetting, } from "../settings/settings"; import styles from "./ReactionsOverlay.module.css"; +import { CallViewModel } from "../state/CallViewModel"; +import { useObservableState } from "observable-hooks"; -export function ReactionsOverlay(): ReactNode { - const { reactions } = useReactions(); +export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { const [showReactions] = useSetting(showReactionsSetting); - const reactionsIcons = useMemo( - () => - showReactions - ? Object.entries(reactions).map(([sender, { emoji }]) => ({ - sender, - emoji, - startX: Math.ceil(Math.random() * 80) + 10, - })) - : [], - [showReactions, reactions], - ); + const reactionsIcons = useObservableState(vm.visibleReactions); + + if (!showReactions) { + return; + } return (
- {reactionsIcons.map(({ sender, emoji, startX }) => ( + {reactionsIcons?.map(({ sender, emoji, startX }) => ( >(); + public readonly reactions = new Subject>(); + + public updateReactions(data: ReturnType) { + this.handsRaised.next(data.raisedHands); + this.reactions.next(data.reactions); + } + + public readonly visibleReactions = combineLatest([ + this.reactions, + showReactions.value, + ]) + .pipe( + map(([reactions, setting]) => (setting ? reactions : {})), + scan< + Record, + { sender: string; emoji: string; startX: number }[] + >((acc, latest) => { + const newSet: { sender: string; emoji: string; startX: number }[] = []; + for (const [sender, reaction] of Object.entries(latest)) { + const startX = + acc.find((v) => v.sender === sender && v.emoji)?.startX ?? + Math.ceil(Math.random() * 80) + 10; + newSet.push({ sender, emoji: reaction.emoji, startX }); + } + return newSet; + }, []), + ) + .pipe(this.scope.state()); + public constructor( // A call is permanently tied to a single Matrix room and LiveKit room private readonly matrixRTCSession: MatrixRTCSession, From fbfa8c3e1d8a7ca54515e95614d6d438bb528be4 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 14:36:58 +0000 Subject: [PATCH 02/11] Drop setting --- src/room/ReactionsOverlay.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index edc967eac..3db2e61f4 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -6,22 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { ReactNode } from "react"; -import { - showReactions as showReactionsSetting, - useSetting, -} from "../settings/settings"; import styles from "./ReactionsOverlay.module.css"; import { CallViewModel } from "../state/CallViewModel"; import { useObservableState } from "observable-hooks"; export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { - const [showReactions] = useSetting(showReactionsSetting); const reactionsIcons = useObservableState(vm.visibleReactions); - - if (!showReactions) { - return; - } - return (
{reactionsIcons?.map(({ sender, emoji, startX }) => ( From 081221731e21a224c9070491e1bdc577eb9b32ff Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 14:58:51 +0000 Subject: [PATCH 03/11] Convert reaction sounds to call view model / rxjs --- src/room/InCallView.tsx | 2 +- src/room/ReactionAudioRenderer.tsx | 42 ++++++++++++++---------------- src/state/CallViewModel.ts | 40 +++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 30 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b13820471..7dbc6aec5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -642,7 +642,7 @@ export const InCallView: FC = ({ {renderContent()} - + {footer} {layout.type !== "pip" && ( diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index 15bfc90f3..1d9f8daaf 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -13,6 +13,7 @@ import { GenericReaction, ReactionSet } from "../reactions"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; import { useLatest } from "../useLatest"; +import { CallViewModel } from "../state/CallViewModel"; const soundMap = Object.fromEntries([ ...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [ @@ -22,8 +23,11 @@ const soundMap = Object.fromEntries([ [GenericReaction.name, GenericReaction.sound], ]); -export function ReactionsAudioRenderer(): ReactNode { - const { reactions } = useReactions(); +export function ReactionsAudioRenderer({ + vm, +}: { + vm: CallViewModel; +}): ReactNode { const [shouldPlay] = useSetting(playReactionsSound); const [soundCache, setSoundCache] = useState { if (!shouldPlay || soundCache) { @@ -46,26 +49,19 @@ export function ReactionsAudioRenderer(): ReactNode { }, [soundCache, shouldPlay]); useEffect(() => { - if (!shouldPlay || !audioEngineRef.current) { - return; - } - const oldReactionSet = new Set( - Object.values(oldReactions).map((r) => r.name), - ); - for (const reactionName of new Set( - Object.values(reactions).map((r) => r.name), - )) { - if (oldReactionSet.has(reactionName)) { - // Don't replay old reactions - return; + const sub = vm.audibleReactions.subscribe((newReactions) => { + for (const reactionName of newReactions) { + if (soundMap[reactionName]) { + audioEngineRef.current?.playSound(reactionName); + } else { + // Fallback sounds. + audioEngineRef.current?.playSound("generic"); + } } - if (soundMap[reactionName]) { - audioEngineRef.current.playSound(reactionName); - } else { - // Fallback sounds. - audioEngineRef.current.playSound("generic"); - } - } - }, [audioEngineRef, shouldPlay, oldReactions, reactions]); + }); + return (): void => { + sub.unsubscribe(); + }; + }, [vm, audioEngineRef]); return null; } diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index c695170ac..d9f12348d 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -26,6 +26,7 @@ import { Subject, combineLatest, concat, + distinct, distinctUntilChanged, filter, forkJoin, @@ -66,7 +67,11 @@ import { } from "./MediaViewModel"; import { accumulate, finalizeValue } from "../utils/observable"; import { ObservableScope } from "./ObservableScope"; -import { duplicateTiles, showReactions } from "../settings/settings"; +import { + duplicateTiles, + playReactionsSound, + showReactions, +} from "../settings/settings"; import { isFirefox } from "../Platform"; import { setPipEnabled } from "../controls"; import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel"; @@ -1101,12 +1106,9 @@ export class CallViewModel extends ViewModel { this.reactions.next(data.reactions); } - public readonly visibleReactions = combineLatest([ - this.reactions, - showReactions.value, - ]) + public readonly visibleReactions = showReactions.value + .pipe(switchMap((show) => (show ? this.reactions : of({})))) .pipe( - map(([reactions, setting]) => (setting ? reactions : {})), scan< Record, { sender: string; emoji: string; startX: number }[] @@ -1123,6 +1125,32 @@ export class CallViewModel extends ViewModel { ) .pipe(this.scope.state()); + public readonly audibleReactions = playReactionsSound.value + .pipe( + switchMap((show) => + show ? this.reactions : of>({}), + ), + ) + .pipe( + map((reactions) => Object.values(reactions).map((v) => v.name)), + scan( + (acc, latest) => { + return { + playing: latest.filter( + (v) => acc.playing.includes(v) || acc.newSounds.includes(v), + ), + newSounds: latest.filter( + (v) => !acc.playing.includes(v) && !acc.newSounds.includes(v), + ), + }; + }, + { playing: [], newSounds: [] }, + ), + map((v) => v.newSounds), + distinct(), + ) + .pipe(this.scope.state()); + public constructor( // A call is permanently tied to a single Matrix room and LiveKit room private readonly matrixRTCSession: MatrixRTCSession, From 73ee088605409cb01ef36b12418e45962a9d6c45 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 15:19:27 +0000 Subject: [PATCH 04/11] Use call view model for hand raised reactions --- src/room/CallEventAudioRenderer.tsx | 20 ++++++-------------- src/state/CallViewModel.ts | 26 +++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index 6f4f0359c..6412d23e2 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { ReactNode, useDeferredValue, useEffect, useMemo } from "react"; -import { filter, interval, throttle } from "rxjs"; +import { filter, interval, map, scan, throttle } from "rxjs"; import { CallViewModel } from "../state/CallViewModel"; import joinCallSoundMp3 from "../sound/join_call.mp3"; @@ -51,19 +51,6 @@ export function CallEventAudioRenderer({ }); const audioEngineRef = useLatest(audioEngineCtx); - const { raisedHands } = useReactions(); - const raisedHandCount = useMemo( - () => Object.keys(raisedHands).length, - [raisedHands], - ); - const previousRaisedHandCount = useDeferredValue(raisedHandCount); - - useEffect(() => { - if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) { - audioEngineRef.current.playSound("raiseHand"); - } - }, [audioEngineRef, previousRaisedHandCount, raisedHandCount]); - useEffect(() => { const joinSub = vm.memberChanges .pipe( @@ -89,9 +76,14 @@ export function CallEventAudioRenderer({ audioEngineRef.current?.playSound("left"); }); + const handRaisedSub = vm.handRaised.subscribe(() => { + audioEngineRef.current?.playSound("raiseHand"); + }); + return (): void => { joinSub.unsubscribe(); leftSub.unsubscribe(); + handRaisedSub.unsubscribe(); }; }, [audioEngineRef, vm]); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d9f12348d..df56fb003 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -26,7 +26,6 @@ import { Subject, combineLatest, concat, - distinct, distinctUntilChanged, filter, forkJoin, @@ -1099,13 +1098,16 @@ export class CallViewModel extends ViewModel { ); public readonly handsRaised = new Subject>(); - public readonly reactions = new Subject>(); + private readonly reactions = new Subject>(); public updateReactions(data: ReturnType) { this.handsRaised.next(data.raisedHands); this.reactions.next(data.reactions); } + /** + * Emits an array of reactions that should be visible on the screen. + */ public readonly visibleReactions = showReactions.value .pipe(switchMap((show) => (show ? this.reactions : of({})))) .pipe( @@ -1125,6 +1127,9 @@ export class CallViewModel extends ViewModel { ) .pipe(this.scope.state()); + /** + * Emits an array of reactions that should be played. + */ public readonly audibleReactions = playReactionsSound.value .pipe( switchMap((show) => @@ -1147,10 +1152,25 @@ export class CallViewModel extends ViewModel { { playing: [], newSounds: [] }, ), map((v) => v.newSounds), - distinct(), ) .pipe(this.scope.state()); + /** + * Emits an event every time a new hand is raised in + * the call. + */ + public readonly handRaised = this.handsRaised.pipe( + map((v) => Object.keys(v).length), + scan( + (acc, newValue) => ({ + value: newValue, + playSounds: newValue > acc.value, + }), + { value: 0, playSounds: false }, + ), + filter((v) => v.playSounds), + ); + public constructor( // A call is permanently tied to a single Matrix room and LiveKit room private readonly matrixRTCSession: MatrixRTCSession, From b10863d582bde674e42723540594dea54209a6e1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 16:09:41 +0000 Subject: [PATCH 05/11] Support raising reactions for matrix rtc members. --- src/room/ReactionAudioRenderer.tsx | 3 +- src/state/CallViewModel.ts | 22 +++++++++- src/state/MediaViewModel.ts | 27 +++++++++++- src/tile/GridTile.tsx | 14 +++---- src/useReactions.tsx | 66 ++++++++++++++++++------------ 5 files changed, 93 insertions(+), 39 deletions(-) diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index 1d9f8daaf..274c8c4c0 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { ReactNode, useDeferredValue, useEffect, useState } from "react"; +import { ReactNode, useEffect, useState } from "react"; -import { useReactions } from "../useReactions"; import { playReactionsSound, useSetting } from "../settings/settings"; import { GenericReaction, ReactionSet } from "../reactions"; import { useAudioContext } from "../useAudioContext"; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index df56fb003..ec2c9c4b0 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -206,6 +206,10 @@ enum SortingBin { * Participants that have been speaking recently. */ Speakers, + /** + * Participants that have their hand raised. + */ + HandRaised, /** * Participants with video. */ @@ -241,6 +245,8 @@ class UserMedia { participant: LocalParticipant | RemoteParticipant | undefined, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, + handRaised: Observable, + reactions: Observable, ) { this.participant = new BehaviorSubject(participant); @@ -251,6 +257,8 @@ class UserMedia { this.participant.asObservable() as Observable, encryptionSystem, livekitRoom, + handRaised, + reactions, ); } else { this.vm = new RemoteUserMediaViewModel( @@ -261,6 +269,8 @@ class UserMedia { >, encryptionSystem, livekitRoom, + handRaised, + reactions, ); } @@ -468,6 +478,8 @@ export class CallViewModel extends ViewModel { let livekitParticipantId = rtcMember.sender + ":" + rtcMember.deviceId; + const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`; + let participant: | LocalParticipant | RemoteParticipant @@ -509,6 +521,12 @@ export class CallViewModel extends ViewModel { participant, this.encryptionSystem, this.livekitRoom, + this.handsRaised.pipe( + map((v) => v[matrixIdentifier] ?? undefined), + ), + this.reactions.pipe( + map((v) => v[matrixIdentifier] ?? undefined), + ), ), ]; @@ -618,12 +636,13 @@ export class CallViewModel extends ViewModel { [ m.speaker, m.presenter, + m.vm.handRaised, m.vm.videoEnabled, m.vm instanceof LocalUserMediaViewModel ? m.vm.alwaysShow : of(false), ], - (speaker, presenter, video, alwaysShow) => { + (speaker, presenter, handRaised, video, alwaysShow) => { let bin: SortingBin; if (m.vm.local) bin = alwaysShow @@ -631,6 +650,7 @@ export class CallViewModel extends ViewModel { : SortingBin.SelfNotAlwaysShown; else if (presenter) bin = SortingBin.Presenters; else if (speaker) bin = SortingBin.Speakers; + else if (handRaised) bin = SortingBin.HandRaised; else if (video) bin = SortingBin.Video; else bin = SortingBin.NoVideo; diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index ceaca57cc..ab12b8196 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -51,6 +51,7 @@ import { alwaysShowSelf } from "../settings/settings"; import { accumulate } from "../utils/observable"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; +import { ReactionOption } from "../reactions"; // TODO: Move this naming logic into the view model export function useDisplayName(vm: MediaViewModel): string { @@ -371,6 +372,8 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { participant: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, + public readonly handRaised: Observable, + public readonly reactions: Observable, ) { super( id, @@ -437,8 +440,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { participant: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, + handRaised: Observable, + reactions: Observable, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super( + id, + member, + participant, + encryptionSystem, + livekitRoom, + handRaised, + reactions, + ); } } @@ -498,8 +511,18 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { participant: Observable, encryptionSystem: EncryptionSystem, livekitRoom: LivekitRoom, + handRaised: Observable, + reactions: Observable, ) { - super(id, member, participant, encryptionSystem, livekitRoom); + super( + id, + member, + participant, + encryptionSystem, + livekitRoom, + handRaised, + reactions, + ); // Sync the local volume with LiveKit combineLatest([ diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 15f7c2954..592635161 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -34,7 +34,7 @@ import { ToggleMenuItem, Menu, } from "@vector-im/compound-web"; -import { useObservableEagerState } from "observable-hooks"; +import { useObservableEagerState, useObservableState } from "observable-hooks"; import styles from "./GridTile.module.css"; import { @@ -49,7 +49,6 @@ import { useLatest } from "../useLatest"; import { GridTileViewModel } from "../state/TileViewModel"; import { useMergedRefs } from "../useMergedRefs"; import { useReactions } from "../useReactions"; -import { ReactionOption } from "../reactions"; interface TileProps { className?: string; @@ -82,6 +81,7 @@ const UserMediaTile = forwardRef( }, ref, ) => { + const { toggleRaisedHand } = useReactions(); const { t } = useTranslation(); const video = useObservableEagerState(vm.video); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); @@ -97,7 +97,8 @@ const UserMediaTile = forwardRef( }, [vm], ); - const { raisedHands, toggleRaisedHand, reactions } = useReactions(); + const handRaised = useObservableState(vm.handRaised); + const reaction = useObservableState(vm.reactions); const AudioIcon = locallyMuted ? VolumeOffSolidIcon @@ -124,9 +125,6 @@ const UserMediaTile = forwardRef( ); - const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; - const currentReaction: ReactionOption | undefined = - reactions[vm.member?.userId ?? ""]; const raisedHandOnClick = vm.local ? (): void => void toggleRaisedHand() : undefined; @@ -144,7 +142,7 @@ const UserMediaTile = forwardRef( videoFit={cropVideo ? "cover" : "contain"} className={classNames(className, styles.tile, { [styles.speaking]: showSpeaking, - [styles.handRaised]: !showSpeaking && !!handRaised, + [styles.handRaised]: !showSpeaking && handRaised, })} nameTagLeadingIcon={ ( } raisedHandTime={handRaised} - currentReaction={currentReaction} + currentReaction={reaction} raisedHandOnClick={raisedHandOnClick} localParticipant={vm.local} {...props} diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 7195cfd06..79898b93b 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -140,10 +140,10 @@ export const ReactionsProvider = ({ }; // Remove any raised hands for users no longer joined to the call. - for (const userId of Object.keys(raisedHands).filter( + for (const identifier of Object.keys(raisedHands).filter( (rhId) => !memberships.find((u) => u.sender == rhId), )) { - removeRaisedHand(userId); + removeRaisedHand(identifier); } // For each member in the call, check to see if a reaction has @@ -152,13 +152,14 @@ export const ReactionsProvider = ({ if (!m.sender || !m.eventId) { continue; } + const identifier = `${m.sender}:${m.deviceId}`; if ( - raisedHands[m.sender] && - raisedHands[m.sender].membershipEventId !== m.eventId + raisedHands[identifier] && + raisedHands[identifier].membershipEventId !== m.eventId ) { // Membership event for sender has changed since the hand // was raised, reset. - removeRaisedHand(m.sender); + removeRaisedHand(identifier); } const reaction = getLastReactionEvent(m.eventId, m.sender); if (reaction) { @@ -166,7 +167,7 @@ export const ReactionsProvider = ({ if (!eventId) { continue; } - addRaisedHand(m.sender, { + addRaisedHand(`${m.sender}:${m.deviceId}`, { membershipEventId: m.eventId, reactionEventId: eventId, time: new Date(reaction.localTimestamp), @@ -181,11 +182,16 @@ export const ReactionsProvider = ({ const latestMemberships = useLatest(memberships); const latestRaisedHands = useLatest(raisedHands); - const myMembership = useMemo( + const myMembershipEvent = useMemo( () => memberships.find((m) => m.sender === myUserId)?.eventId, [memberships, myUserId], ); - + const myMembershipIdentifier = useMemo(() => { + const membership = memberships.find((m) => m.sender === myUserId); + return membership + ? `${membership.sender}:${membership.deviceId}` + : undefined; + }, [memberships, myUserId]); // This effect handles any *live* reaction/redactions in the room. useEffect(() => { const reactionTimeouts = new Set(); @@ -271,11 +277,10 @@ export const ReactionsProvider = ({ // Check to see if this reaction was made to a membership event (and the // sender of the reaction matches the membership) - if ( - !latestMemberships.current.some( - (e) => e.eventId === membershipEventId && e.sender === sender, - ) - ) { + const membershipEvent = latestMemberships.current.find( + (e) => e.eventId === membershipEventId && e.sender === sender, + ); + if (!membershipEvent) { logger.warn( `Reaction target was not a membership event for ${sender}, ignoring`, ); @@ -283,11 +288,14 @@ export const ReactionsProvider = ({ } if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(sender, { - reactionEventId, - membershipEventId, - time: new Date(event.localTimestamp), - }); + addRaisedHand( + `${membershipEvent.sender}:${membershipEvent.deviceId}`, + { + reactionEventId, + membershipEventId, + time: new Date(event.localTimestamp), + }, + ); } } else if (event.getType() === EventType.RoomRedaction) { const targetEvent = event.event.redacts; @@ -328,14 +336,14 @@ export const ReactionsProvider = ({ ]); const toggleRaisedHand = useCallback(async () => { - if (!myUserId) { + if (!myMembershipIdentifier) { return; } - const myReactionId = raisedHands[myUserId]?.reactionEventId; + const myReactionId = raisedHands[myMembershipIdentifier]?.reactionEventId; if (!myReactionId) { try { - if (!myMembership) { + if (!myMembershipEvent) { throw new Error("Cannot find own membership event"); } const reaction = await room.client.sendEvent( @@ -344,7 +352,7 @@ export const ReactionsProvider = ({ { "m.relates_to": { rel_type: RelationType.Annotation, - event_id: myMembership, + event_id: myMembershipEvent, key: "🖐️", }, }, @@ -362,7 +370,13 @@ export const ReactionsProvider = ({ throw ex; } } - }, [myMembership, myUserId, raisedHands, rtcSession, room]); + }, [ + myMembershipEvent, + myMembershipIdentifier, + raisedHands, + rtcSession, + room, + ]); const sendReaction = useCallback( async (reaction: ReactionOption) => { @@ -370,7 +384,7 @@ export const ReactionsProvider = ({ // We're still reacting return; } - if (!myMembership) { + if (!myMembershipEvent) { throw new Error("Cannot find own membership event"); } await room.client.sendEvent( @@ -379,14 +393,14 @@ export const ReactionsProvider = ({ { "m.relates_to": { rel_type: RelationType.Reference, - event_id: myMembership, + event_id: myMembershipEvent, }, emoji: reaction.emoji, name: reaction.name, }, ); }, - [myMembership, reactions, room, myUserId, rtcSession], + [myMembershipEvent, reactions, room, myUserId, rtcSession], ); return ( From de19565c4f4c87e3221f012b168903667a8b408a Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 16:24:17 +0000 Subject: [PATCH 06/11] Tie up last bits of useReactions --- src/button/ReactionToggleButton.tsx | 26 +++++++----- src/room/CallEventAudioRenderer.tsx | 5 +-- src/room/InCallView.tsx | 3 +- src/state/CallViewModel.ts | 2 +- src/useReactions.tsx | 61 +++++++++++++++++------------ 5 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index b1d6ec3e5..0d98173ec 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -29,6 +29,9 @@ import { useReactions } from "../useReactions"; import styles from "./ReactionToggleButton.module.css"; import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions"; import { Modal } from "../Modal"; +import { CallViewModel } from "../state/CallViewModel"; +import { useObservableState } from "observable-hooks"; +import { map } from "rxjs"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -158,22 +161,27 @@ export function ReactionPopupMenu({ } interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> { - userId: string; + identifier: string; + vm: CallViewModel; } export function ReactionToggleButton({ - userId, + identifier, + vm, ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); - const { raisedHands, toggleRaisedHand, sendReaction, reactions } = - useReactions(); + const { toggleRaisedHand, sendReaction } = useReactions(); const [busy, setBusy] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); - const isHandRaised = !!raisedHands[userId]; - const canReact = !reactions[userId]; + const isHandRaised = useObservableState( + vm.handsRaised.pipe(map((v) => !!v[identifier])), + ); + const canReact = useObservableState( + vm.reactions.pipe(map((v) => !!v[identifier])), + ); useEffect(() => { // Clear whenever the reactions menu state changes. @@ -219,7 +227,7 @@ export function ReactionToggleButton({ setShowReactionsMenu((show) => !show)} - raised={isHandRaised} + raised={!!isHandRaised} open={showReactionsMenu} {...props} /> @@ -233,8 +241,8 @@ export function ReactionToggleButton({ > void sendRelation(reaction)} toggleRaisedHand={wrappedToggleRaisedHand} /> diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index 6412d23e2..42ddae81c 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -5,8 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { ReactNode, useDeferredValue, useEffect, useMemo } from "react"; -import { filter, interval, map, scan, throttle } from "rxjs"; +import { ReactNode, useEffect } from "react"; +import { filter, interval, throttle } from "rxjs"; import { CallViewModel } from "../state/CallViewModel"; import joinCallSoundMp3 from "../sound/join_call.mp3"; @@ -17,7 +17,6 @@ import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url"; import { useAudioContext } from "../useAudioContext"; import { prefetchSounds } from "../soundUtils"; -import { useReactions } from "../useReactions"; import { useLatest } from "../useLatest"; // Do not play any sounds if the participant count has exceeded this diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7dbc6aec5..ef1a65de5 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -544,9 +544,10 @@ export const InCallView: FC = ({ if (supportsReactions) { buttons.push( , ); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index ec2c9c4b0..9a638ee33 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -1118,7 +1118,7 @@ export class CallViewModel extends ViewModel { ); public readonly handsRaised = new Subject>(); - private readonly reactions = new Subject>(); + public readonly reactions = new Subject>(); public updateReactions(data: ReturnType) { this.handsRaised.next(data.raisedHands); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 79898b93b..a250fbf37 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -37,8 +37,14 @@ import { import { useLatest } from "./useLatest"; interface ReactionsContextType { + /** + * identifier (userId:deviceId => Date) + */ raisedHands: Record; supportsReactions: boolean; + /** + * reactions (userId:deviceId => Date) + */ reactions: Record; toggleRaisedHand: () => Promise; sendReaction: (reaction: ReactionOption) => Promise; @@ -92,6 +98,24 @@ export const ReactionsProvider = ({ clientState?.state === "valid" && clientState.supportedFeatures.reactions; const room = rtcSession.room; const myUserId = room.client.getUserId(); + const myDeviceId = room.client.getDeviceId(); + + const latestMemberships = useLatest(memberships); + const latestRaisedHands = useLatest(raisedHands); + + const myMembershipEvent = useMemo( + () => + memberships.find( + (m) => m.sender === myUserId && m.deviceId === myDeviceId, + )?.eventId, + [memberships, myUserId], + ); + const myMembershipIdentifier = useMemo(() => { + const membership = memberships.find((m) => m.sender === myUserId); + return membership + ? `${membership.sender}:${membership.deviceId}` + : undefined; + }, [memberships, myUserId]); const [reactions, setReactions] = useState>( {}, @@ -177,21 +201,8 @@ export const ReactionsProvider = ({ // Ignoring raisedHands here because we don't want to trigger each time the raised // hands set is updated. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [room, memberships, myUserId, addRaisedHand, removeRaisedHand]); + }, [room, memberships, addRaisedHand, removeRaisedHand]); - const latestMemberships = useLatest(memberships); - const latestRaisedHands = useLatest(raisedHands); - - const myMembershipEvent = useMemo( - () => memberships.find((m) => m.sender === myUserId)?.eventId, - [memberships, myUserId], - ); - const myMembershipIdentifier = useMemo(() => { - const membership = memberships.find((m) => m.sender === myUserId); - return membership - ? `${membership.sender}:${membership.deviceId}` - : undefined; - }, [memberships, myUserId]); // This effect handles any *live* reaction/redactions in the room. useEffect(() => { const reactionTimeouts = new Set(); @@ -215,18 +226,18 @@ export const ReactionsProvider = ({ const content: ECallReactionEventContent = event.getContent(); const membershipEventId = content?.["m.relates_to"]?.event_id; + const membershipEvent = latestMemberships.current.find( + (e) => e.eventId === membershipEventId && e.sender === sender, + ); // Check to see if this reaction was made to a membership event (and the // sender of the reaction matches the membership) - if ( - !latestMemberships.current.some( - (e) => e.eventId === membershipEventId && e.sender === sender, - ) - ) { + if (!membershipEvent) { logger.warn( `Reaction target was not a membership event for ${sender}, ignoring`, ); return; } + const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`; if (!content.emoji) { logger.warn(`Reaction had no emoji from ${reactionEventId}`); @@ -256,19 +267,21 @@ export const ReactionsProvider = ({ }; setReactions((reactions) => { - if (reactions[sender]) { + if (reactions[identifier]) { // We've still got a reaction from this user, ignore it to prevent spamming return reactions; } const timeout = window.setTimeout(() => { // Clear the reaction after some time. - setReactions(({ [sender]: _unused, ...remaining }) => remaining); + setReactions( + ({ [identifier]: _unused, ...remaining }) => remaining, + ); reactionTimeouts.delete(timeout); }, REACTION_ACTIVE_TIME_MS); reactionTimeouts.add(timeout); return { ...reactions, - [sender]: reaction, + [identifier]: reaction, }; }); } else if (event.getType() === EventType.Reaction) { @@ -380,7 +393,7 @@ export const ReactionsProvider = ({ const sendReaction = useCallback( async (reaction: ReactionOption) => { - if (!myUserId || reactions[myUserId]) { + if (!myMembershipIdentifier || !reactions[myMembershipIdentifier]) { // We're still reacting return; } @@ -400,7 +413,7 @@ export const ReactionsProvider = ({ }, ); }, - [myMembershipEvent, reactions, room, myUserId, rtcSession], + [myMembershipEvent, reactions, room, myMembershipIdentifier, rtcSession], ); return ( From ed35049eb37dbffe28d372163a00664cd976c120 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 9 Dec 2024 16:44:28 +0000 Subject: [PATCH 07/11] linting --- src/button/ReactionToggleButton.tsx | 4 ++-- src/room/ReactionsOverlay.tsx | 3 ++- src/state/CallViewModel.ts | 15 ++++++++++----- src/useReactions.tsx | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 0d98173ec..f593d6ebd 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -24,14 +24,14 @@ import { import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/src/logger"; import classNames from "classnames"; +import { useObservableState } from "observable-hooks"; +import { map } from "rxjs"; import { useReactions } from "../useReactions"; import styles from "./ReactionToggleButton.module.css"; import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions"; import { Modal } from "../Modal"; import { CallViewModel } from "../state/CallViewModel"; -import { useObservableState } from "observable-hooks"; -import { map } from "rxjs"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; diff --git a/src/room/ReactionsOverlay.tsx b/src/room/ReactionsOverlay.tsx index 3db2e61f4..dad23bf7e 100644 --- a/src/room/ReactionsOverlay.tsx +++ b/src/room/ReactionsOverlay.tsx @@ -6,9 +6,10 @@ Please see LICENSE in the repository root for full details. */ import { ReactNode } from "react"; +import { useObservableState } from "observable-hooks"; + import styles from "./ReactionsOverlay.module.css"; import { CallViewModel } from "../state/CallViewModel"; -import { useObservableState } from "observable-hooks"; export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { const reactionsIcons = useObservableState(vm.visibleReactions); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 9a638ee33..612516001 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -1117,12 +1117,17 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - public readonly handsRaised = new Subject>(); - public readonly reactions = new Subject>(); + private readonly handsRaisedSubject = new Subject>(); + private readonly reactionsSubject = new Subject< + Record + >(); - public updateReactions(data: ReturnType) { - this.handsRaised.next(data.raisedHands); - this.reactions.next(data.reactions); + public readonly handRaised = this.handsRaisedSubject.asObservable(); + public readonly reactions = this.reactionsSubject.asObservable(); + + public updateReactions(data: ReturnType): void { + this.handsRaisedSubject.next(data.raisedHands); + this.reactionsSubject.next(data.reactions); } /** diff --git a/src/useReactions.tsx b/src/useReactions.tsx index a250fbf37..0470ab0c0 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -108,7 +108,7 @@ export const ReactionsProvider = ({ memberships.find( (m) => m.sender === myUserId && m.deviceId === myDeviceId, )?.eventId, - [memberships, myUserId], + [memberships, myUserId, myDeviceId], ); const myMembershipIdentifier = useMemo(() => { const membership = memberships.find((m) => m.sender === myUserId); From fe5095f76ccb0cb2764b46d5f426feade0b18a34 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 10 Dec 2024 11:13:09 +0000 Subject: [PATCH 08/11] Update calleventaudiorenderer --- src/room/CallEventAudioRenderer.test.tsx | 40 ++++++++++++++++++++++++ src/state/CallViewModel.ts | 8 +++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 5bb1ba192..2be2d1865 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -199,3 +199,43 @@ test("plays no sound when the participant list is more than the maximum size", ( }); expect(playSound).toBeCalledWith("left"); }); + +test("plays one sound when a hand is raised", () => { + const { session, vm } = getMockEnv([local, alice]); + render(); + // Joining a call usually means remote participants are added later. + act(() => { + vm.updateReactions({ + raisedHands: { + [bobRtcMember.callId]: new Date(), + }, + reactions: {}, + }); + }); + expect(playSound).toBeCalledWith("raiseHand"); +}); + +test("should not play a sound when a hand raise is retracted", () => { + const { session, vm } = getMockEnv([local, alice]); + render(); + // Joining a call usually means remote participants are added later. + act(() => { + vm.updateReactions({ + raisedHands: { + ["foo"]: new Date(), + ["bar"]: new Date(), + }, + reactions: {}, + }); + }); + expect(playSound).toHaveBeenCalledTimes(2); + act(() => { + vm.updateReactions({ + raisedHands: { + ["foo"]: new Date(), + }, + reactions: {}, + }); + }); + expect(playSound).toHaveBeenCalledTimes(2); +}); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 612516001..06fb14bd0 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -81,7 +81,6 @@ import { oneOnOneLayout } from "./OneOnOneLayout"; import { pipLayout } from "./PipLayout"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { observeSpeaker } from "./observeSpeaker"; -import { useReactions } from "../useReactions"; import { ReactionOption } from "../reactions"; // How long we wait after a focus switch before showing the real participant @@ -1122,10 +1121,13 @@ export class CallViewModel extends ViewModel { Record >(); - public readonly handRaised = this.handsRaisedSubject.asObservable(); + public readonly handsRaised = this.handsRaisedSubject.asObservable(); public readonly reactions = this.reactionsSubject.asObservable(); - public updateReactions(data: ReturnType): void { + public updateReactions(data: { + raisedHands: Record; + reactions: Record; + }): void { this.handsRaisedSubject.next(data.raisedHands); this.reactionsSubject.next(data.reactions); } From 8d82daa742d930930606fba8e63e87feff3fc79d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 10 Dec 2024 11:13:14 +0000 Subject: [PATCH 09/11] Update reaction audio renderer --- src/room/ReactionAudioRenderer.test.tsx | 137 ++++++++++++++++-------- 1 file changed, 90 insertions(+), 47 deletions(-) diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index 2fec8a9ac..56152a729 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -19,11 +19,6 @@ import { TooltipProvider } from "@vector-im/compound-web"; import { act, ReactNode } from "react"; import { afterEach } from "node:test"; -import { - MockRoom, - MockRTCSession, - TestReactionsWrapper, -} from "../utils/testReactions"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { playReactionsSound, @@ -32,33 +27,73 @@ import { import { useAudioContext } from "../useAudioContext"; import { GenericReaction, ReactionSet } from "../reactions"; import { prefetchSounds } from "../soundUtils"; - -const memberUserIdAlice = "@alice:example.org"; -const memberUserIdBob = "@bob:example.org"; -const memberUserIdCharlie = "@charlie:example.org"; -const memberEventAlice = "$membership-alice:example.org"; -const memberEventBob = "$membership-bob:example.org"; -const memberEventCharlie = "$membership-charlie:example.org"; - -const membership: Record = { - [memberEventAlice]: memberUserIdAlice, - [memberEventBob]: memberUserIdBob, - [memberEventCharlie]: memberUserIdCharlie, -}; - -function TestComponent({ - rtcSession, -}: { - rtcSession: MockRTCSession; -}): ReactNode { +import { ConnectionState } from "livekit-client"; +import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; +import { BehaviorSubject, of } from "rxjs"; +import { E2eeType } from "../e2ee/e2eeType"; +import { CallViewModel } from "../state/CallViewModel"; +import { + mockLivekitRoom, + mockLocalParticipant, + mockMatrixRoom, + mockMatrixRoomMember, + mockRemoteParticipant, + mockRtcMembership, + MockRTCSession, +} from "../utils/test"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); +const local = mockMatrixRoomMember(localRtcMember); +const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); +const alice = mockMatrixRoomMember(aliceRtcMember); +const localParticipant = mockLocalParticipant({ identity: "" }); +const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`; +const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); + +function TestComponent({ vm }: { vm: CallViewModel }): ReactNode { return ( - - - + ); } +function testEnv(): CallViewModel { + const matrixRoomMembers = new Map([local, alice].map((p) => [p.userId, p])); + const remoteParticipants = of([aliceParticipant]); + const liveKitRoom = mockLivekitRoom( + { localParticipant }, + { remoteParticipants }, + ); + const matrixRoom = mockMatrixRoom({ + client: { + getUserId: () => localRtcMember.sender, + getDeviceId: () => localRtcMember.deviceId, + on: vitest.fn(), + off: vitest.fn(), + } as Partial as MatrixClient, + getMember: (userId) => matrixRoomMembers.get(userId) ?? null, + }); + + const remoteRtcMemberships = new BehaviorSubject([ + aliceRtcMember, + ]); + + const session = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships(remoteRtcMemberships); + + const vm = new CallViewModel( + session as unknown as MatrixRTCSession, + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + return vm; +} vitest.mock("../useAudioContext"); vitest.mock("../soundUtils"); @@ -88,20 +123,16 @@ beforeEach(() => { }); test("preloads all audio elements", () => { + const vm = testEnv(); playReactionsSound.setValue(true); - const rtcSession = new MockRTCSession( - new MockRoom(memberUserIdAlice), - membership, - ); - render(); + render(); expect(prefetchSounds).toHaveBeenCalledOnce(); }); test("will play an audio sound when there is a reaction", () => { + const vm = testEnv(); playReactionsSound.setValue(true); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); - render(); + render(); // Find the first reaction with a sound effect const chosenReaction = ReactionSet.find((r) => !!r.sound); @@ -111,16 +142,20 @@ test("will play an audio sound when there is a reaction", () => { ); } act(() => { - room.testSendReaction(memberEventAlice, chosenReaction, membership); + vm.updateReactions({ + raisedHands: {}, + reactions: { + memberEventAlice: chosenReaction, + }, + }); }); expect(playSound).toHaveBeenCalledWith(chosenReaction.name); }); test("will play the generic audio sound when there is soundless reaction", () => { + const vm = testEnv(); playReactionsSound.setValue(true); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); - render(); + render(); // Find the first reaction with a sound effect const chosenReaction = ReactionSet.find((r) => !r.sound); @@ -130,17 +165,20 @@ test("will play the generic audio sound when there is soundless reaction", () => ); } act(() => { - room.testSendReaction(memberEventAlice, chosenReaction, membership); + vm.updateReactions({ + raisedHands: {}, + reactions: { + memberEventAlice: chosenReaction, + }, + }); }); expect(playSound).toHaveBeenCalledWith(GenericReaction.name); }); test("will play multiple audio sounds when there are multiple different reactions", () => { + const vm = testEnv(); playReactionsSound.setValue(true); - - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); - render(); + render(); // Find the first reaction with a sound effect const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound); @@ -150,9 +188,14 @@ test("will play multiple audio sounds when there are multiple different reaction ); } act(() => { - room.testSendReaction(memberEventAlice, reaction1, membership); - room.testSendReaction(memberEventBob, reaction2, membership); - room.testSendReaction(memberEventCharlie, reaction1, membership); + vm.updateReactions({ + raisedHands: {}, + reactions: { + memberEventAlice: reaction1, + memberEventBob: reaction2, + memberEventCharlie: reaction1, + }, + }); }); expect(playSound).toHaveBeenCalledWith(reaction1.name); expect(playSound).toHaveBeenCalledWith(reaction2.name); From 12a412ce11b5f1d12abe225490437f082a0917cc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 10 Dec 2024 13:41:04 +0000 Subject: [PATCH 10/11] more test bits --- src/tile/GridTile.test.tsx | 1 + src/utils/test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index c0cf9c480..41476bdb0 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -44,6 +44,7 @@ test("GridTile is accessible", async () => { off: () => {}, client: { getUserId: () => null, + getDeviceId: () => null, on: () => {}, off: () => {}, }, diff --git a/src/utils/test.ts b/src/utils/test.ts index 459a252e2..93a4c133b 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -200,6 +200,8 @@ export async function withLocalMedia( kind: E2eeType.PER_PARTICIPANT, }, mockLivekitRoom({ localParticipant }), + of(undefined), + of(undefined), ); try { await continuation(vm); @@ -236,6 +238,8 @@ export async function withRemoteMedia( kind: E2eeType.PER_PARTICIPANT, }, mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }), + of(undefined), + of(undefined), ); try { await continuation(vm); From 19e5c67a37e0f87c2576c6ff65d2b3b7c391cb41 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 12 Dec 2024 09:00:17 +0000 Subject: [PATCH 11/11] All the test bits and pieces --- src/button/ReactionToggleButton.test.tsx | 104 ++++++++----- .../ReactionToggleButton.test.tsx.snap | 10 +- src/room/CallEventAudioRenderer.test.tsx | 139 +++++------------- src/room/ReactionsOverlay.test.tsx | 101 +++++-------- src/state/CallViewModel.test.ts | 64 +++++++- src/state/CallViewModel.ts | 20 ++- src/useReactions.test.tsx | 18 +-- src/useReactions.tsx | 1 + src/utils/test-fixtures.ts | 17 +++ src/utils/test-viewmodel.ts | 71 +++++++++ src/utils/test.ts | 16 +- src/utils/testReactions.tsx | 83 +++++------ 12 files changed, 369 insertions(+), 275 deletions(-) create mode 100644 src/utils/test-fixtures.ts create mode 100644 src/utils/test-viewmodel.ts diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index a14983049..10c749817 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -5,47 +5,45 @@ 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, render } from "@testing-library/react"; import { expect, test } from "vitest"; import { TooltipProvider } from "@vector-im/compound-web"; import { userEvent } from "@testing-library/user-event"; import { ReactNode } from "react"; -import { - MockRoom, - MockRTCSession, - TestReactionsWrapper, -} from "../utils/testReactions"; +import { MockRoom } from "../utils/testReactions"; import { ReactionToggleButton } from "./ReactionToggleButton"; import { ElementCallReactionEventType } from "../reactions"; +import { CallViewModel } from "../state/CallViewModel"; +import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; +import { alice, local, localRtcMember } from "../utils/test-fixtures"; +import { MockRTCSession } from "../utils/test"; +import { ReactionsProvider } from "../useReactions"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; -const memberUserIdAlice = "@alice:example.org"; -const memberEventAlice = "$membership-alice:example.org"; - -const membership: Record = { - [memberEventAlice]: memberUserIdAlice, -}; +const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`; function TestComponent({ rtcSession, + vm, }: { rtcSession: MockRTCSession; + vm: CallViewModel; }): ReactNode { return ( - - - + + + ); } test("Can open menu", async () => { const user = userEvent.setup(); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const { vm, rtcSession } = getBasicCallViewModelEnvironment([alice]); const { getByLabelText, container } = render( - , + , ); await user.click(getByLabelText("common.reactions")); expect(container).toMatchSnapshot(); @@ -53,40 +51,68 @@ test("Can open menu", async () => { test("Can raise hand", async () => { const user = userEvent.setup(); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]); const { getByLabelText, container } = render( - , + , ); await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("action.raise_hand")); - expect(room.testSentEvents).toEqual([ - [ - undefined, - "m.reaction", - { - "m.relates_to": { - event_id: memberEventAlice, - key: "🖐️", - rel_type: "m.annotation", - }, + expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith( + undefined, + "m.reaction", + { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + rel_type: "m.annotation", }, - ], - ]); + }, + ); + await act(() => { + vm.updateReactions({ + raisedHands: { + [localIdent]: new Date(), + }, + reactions: {}, + }); + }); expect(container).toMatchSnapshot(); }); -test("Can lower hand", async () => { +test.only("Can lower hand", async () => { const user = userEvent.setup(); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]); const { getByLabelText, container } = render( - , + , ); - const reactionEvent = room.testSendHandRaise(memberEventAlice, membership); await user.click(getByLabelText("common.reactions")); + await user.click(getByLabelText("action.raise_hand")); + await act(() => { + vm.updateReactions({ + raisedHands: { + [localIdent]: new Date(), + }, + reactions: {}, + }); + }); await user.click(getByLabelText("action.lower_hand")); - expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]); + expect(rtcSession.room.client.redactEvent).toHaveBeenCalledWith( + undefined, + "m.reaction", + { + "m.relates_to": { + event_id: localRtcMember.eventId, + key: "🖐️", + rel_type: "m.annotation", + }, + }, + ); + await act(() => { + vm.updateReactions({ + raisedHands: {}, + reactions: {}, + }); + }); expect(container).toMatchSnapshot(); }); diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap index dd4227e1b..b1b5df353 100644 --- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap +++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap @@ -137,9 +137,9 @@ exports[`Can raise hand 1`] = ` aria-disabled="false" aria-expanded="false" aria-haspopup="true" - aria-labelledby=":r1j:" - class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" - data-kind="secondary" + aria-labelledby=":r0:" + class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59" + data-kind="primary" data-size="lg" role="button" tabindex="0" @@ -153,9 +153,7 @@ exports[`Can raise hand 1`] = ` xmlns="http://www.w3.org/2000/svg" > diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 2be2d1865..11be2a06c 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -14,44 +14,24 @@ import { test, vitest, } from "vitest"; -import { MatrixClient } from "matrix-js-sdk/src/client"; -import { ConnectionState } from "livekit-client"; -import { BehaviorSubject, of } from "rxjs"; import { afterEach } from "node:test"; -import { act, ReactNode } from "react"; -import { - CallMembership, - type MatrixRTCSession, -} from "matrix-js-sdk/src/matrixrtc"; -import { RoomMember } from "matrix-js-sdk/src/matrix"; +import { act } from "react"; +import { CallMembership } from "matrix-js-sdk/src/matrixrtc"; -import { - mockLivekitRoom, - mockLocalParticipant, - mockMatrixRoom, - mockMatrixRoomMember, - mockRemoteParticipant, - mockRtcMembership, - MockRTCSession, -} from "../utils/test"; -import { E2eeType } from "../e2ee/e2eeType"; -import { CallViewModel } from "../state/CallViewModel"; +import { mockRtcMembership } from "../utils/test"; import { CallEventAudioRenderer, MAX_PARTICIPANT_COUNT_FOR_SOUND, } from "./CallEventAudioRenderer"; import { useAudioContext } from "../useAudioContext"; -import { TestReactionsWrapper } from "../utils/testReactions"; import { prefetchSounds } from "../soundUtils"; - -const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); -const local = mockMatrixRoomMember(localRtcMember); -const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); -const alice = mockMatrixRoomMember(aliceRtcMember); -const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); -const localParticipant = mockLocalParticipant({ identity: "" }); -const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`; -const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); +import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; +import { + alice, + aliceRtcMember, + bobRtcMember, + local, +} from "../utils/test-fixtures"; vitest.mock("../useAudioContext"); vitest.mock("../soundUtils"); @@ -78,66 +58,6 @@ beforeEach(() => { }); }); -function TestComponent({ - rtcSession, - vm, -}: { - rtcSession: MockRTCSession; - vm: CallViewModel; -}): ReactNode { - return ( - - - - ); -} - -function getMockEnv( - members: RoomMember[], - initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember], -): { - vm: CallViewModel; - session: MockRTCSession; - remoteRtcMemberships: BehaviorSubject; -} { - const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); - const remoteParticipants = of([aliceParticipant]); - const liveKitRoom = mockLivekitRoom( - { localParticipant }, - { remoteParticipants }, - ); - const matrixRoom = mockMatrixRoom({ - client: { - getUserId: () => localRtcMember.sender, - getDeviceId: () => localRtcMember.deviceId, - on: vitest.fn(), - off: vitest.fn(), - } as Partial as MatrixClient, - getMember: (userId) => matrixRoomMembers.get(userId) ?? null, - }); - - const remoteRtcMemberships = new BehaviorSubject( - initialRemoteRtcMemberships, - ); - - const session = new MockRTCSession( - matrixRoom, - localRtcMember, - ).withMemberships(remoteRtcMemberships); - - const vm = new CallViewModel( - session as unknown as MatrixRTCSession, - liveKitRoom, - { - kind: E2eeType.PER_PARTICIPANT, - }, - of(ConnectionState.Connected), - ); - return { vm, session, remoteRtcMemberships }; -} - /** * We don't want to play a sound when loading the call state * because typically this occurs in two stages. We first join @@ -146,8 +66,12 @@ function getMockEnv( * a noise every time. */ test("plays one sound when entering a call", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); - render(); + const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([ + local, + alice, + ]); + render(); + // Joining a call usually means remote participants are added later. act(() => { remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); @@ -155,10 +79,12 @@ test("plays one sound when entering a call", () => { expect(playSound).toHaveBeenCalledOnce(); }); -// TODO: Same test? test("plays a sound when a user joins", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); - render(); + const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([ + local, + alice, + ]); + render(); act(() => { remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); @@ -168,8 +94,11 @@ test("plays a sound when a user joins", () => { }); test("plays a sound when a user leaves", () => { - const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); - render(); + const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([ + local, + alice, + ]); + render(); act(() => { remoteRtcMemberships.next([]); @@ -185,12 +114,12 @@ test("plays no sound when the participant list is more than the maximum size", ( ); } - const { session, vm, remoteRtcMemberships } = getMockEnv( + const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment( [local, alice], mockRtcMemberships, ); - render(); + render(); expect(playSound).not.toBeCalled(); act(() => { remoteRtcMemberships.next( @@ -201,9 +130,9 @@ test("plays no sound when the participant list is more than the maximum size", ( }); test("plays one sound when a hand is raised", () => { - const { session, vm } = getMockEnv([local, alice]); - render(); - // Joining a call usually means remote participants are added later. + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + render(); + act(() => { vm.updateReactions({ raisedHands: { @@ -216,9 +145,9 @@ test("plays one sound when a hand is raised", () => { }); test("should not play a sound when a hand raise is retracted", () => { - const { session, vm } = getMockEnv([local, alice]); - render(); - // Joining a call usually means remote participants are added later. + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + render(); + act(() => { vm.updateReactions({ raisedHands: { diff --git a/src/room/ReactionsOverlay.test.tsx b/src/room/ReactionsOverlay.test.tsx index 121594aba..04d647fdd 100644 --- a/src/room/ReactionsOverlay.test.tsx +++ b/src/room/ReactionsOverlay.test.tsx @@ -7,45 +7,19 @@ Please see LICENSE in the repository root for full details. import { render } from "@testing-library/react"; import { expect, test } from "vitest"; -import { TooltipProvider } from "@vector-im/compound-web"; -import { act, ReactNode } from "react"; +import { act } from "react"; import { afterEach } from "node:test"; -import { - MockRoom, - MockRTCSession, - TestReactionsWrapper, -} from "../utils/testReactions"; import { showReactions } from "../settings/settings"; import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionSet } from "../reactions"; - -const memberUserIdAlice = "@alice:example.org"; -const memberUserIdBob = "@bob:example.org"; -const memberUserIdCharlie = "@charlie:example.org"; -const memberEventAlice = "$membership-alice:example.org"; -const memberEventBob = "$membership-bob:example.org"; -const memberEventCharlie = "$membership-charlie:example.org"; - -const membership: Record = { - [memberEventAlice]: memberUserIdAlice, - [memberEventBob]: memberUserIdBob, - [memberEventCharlie]: memberUserIdCharlie, -}; - -function TestComponent({ - rtcSession, -}: { - rtcSession: MockRTCSession; -}): ReactNode { - return ( - - - - - - ); -} +import { + local, + alice, + aliceRtcMember, + bobRtcMember, +} from "../utils/test-fixtures"; +import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; afterEach(() => { showReactions.setValue(showReactions.defaultValue); @@ -53,22 +27,21 @@ afterEach(() => { test("defaults to showing no reactions", () => { showReactions.setValue(true); - const rtcSession = new MockRTCSession( - new MockRoom(memberUserIdAlice), - membership, - ); - const { container } = render(); + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { container } = render(); expect(container.getElementsByTagName("span")).toHaveLength(0); }); test("shows a reaction when sent", () => { showReactions.setValue(true); + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { getByRole } = render(); const reaction = ReactionSet[0]; - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); - const { getByRole } = render(); act(() => { - room.testSendReaction(memberEventAlice, reaction, membership); + vm.updateReactions({ + reactions: { [aliceRtcMember.deviceId]: reaction }, + raisedHands: {}, + }); }); const span = getByRole("presentation"); expect(getByRole("presentation")).toBeTruthy(); @@ -78,29 +51,33 @@ test("shows a reaction when sent", () => { test("shows two of the same reaction when sent", () => { showReactions.setValue(true); const reaction = ReactionSet[0]; - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); - const { getAllByRole } = render(); + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { getAllByRole } = render(); act(() => { - room.testSendReaction(memberEventAlice, reaction, membership); - }); - act(() => { - room.testSendReaction(memberEventBob, reaction, membership); + vm.updateReactions({ + reactions: { + [aliceRtcMember.deviceId]: reaction, + [bobRtcMember.deviceId]: reaction, + }, + raisedHands: {}, + }); }); expect(getAllByRole("presentation")).toHaveLength(2); }); test("shows two different reactions when sent", () => { showReactions.setValue(true); - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); const [reactionA, reactionB] = ReactionSet; - const { getAllByRole } = render(); - act(() => { - room.testSendReaction(memberEventAlice, reactionA, membership); - }); + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { getAllByRole } = render(); act(() => { - room.testSendReaction(memberEventBob, reactionB, membership); + vm.updateReactions({ + reactions: { + [aliceRtcMember.deviceId]: reactionA, + [bobRtcMember.deviceId]: reactionB, + }, + raisedHands: {}, + }); }); const [reactionElementA, reactionElementB] = getAllByRole("presentation"); expect(reactionElementA.innerHTML).toEqual(reactionA.emoji); @@ -110,11 +87,13 @@ test("shows two different reactions when sent", () => { test("hides reactions when reaction animations are disabled", () => { showReactions.setValue(false); const reaction = ReactionSet[0]; - const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { container } = render(); act(() => { - room.testSendReaction(memberEventAlice, reaction, membership); + vm.updateReactions({ + reactions: { [aliceRtcMember.deviceId]: reaction }, + raisedHands: {}, + }); }); - const { container } = render(); expect(container.getElementsByTagName("span")).toHaveLength(0); }); diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 5dbfb1ca7..76762047b 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { test, vi, onTestFinished, it } from "vitest"; +import { test, vi, onTestFinished, it, vitest } from "vitest"; import { combineLatest, debounceTime, @@ -14,6 +14,7 @@ import { Observable, of, switchMap, + tap, } from "rxjs"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { @@ -684,3 +685,64 @@ it("should show at least one tile per MatrixRTCSession", () => { ); }); }); + +// TODO: Add presenters and speakers? +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 = "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 06fb14bd0..e0cf0aa2b 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -42,6 +42,7 @@ import { switchMap, switchScan, take, + tap, timer, withLatestFrom, } from "rxjs"; @@ -635,13 +636,14 @@ export class CallViewModel extends ViewModel { [ m.speaker, m.presenter, - m.vm.handRaised, m.vm.videoEnabled, m.vm instanceof LocalUserMediaViewModel ? m.vm.alwaysShow : of(false), + m.vm.handRaised, ], - (speaker, presenter, handRaised, video, alwaysShow) => { + (speaker, presenter, video, alwaysShow, handRaised) => { + console.log(m.vm.id, handRaised); let bin: SortingBin; if (m.vm.local) bin = alwaysShow @@ -664,6 +666,12 @@ 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 = @@ -1116,10 +1124,12 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly handsRaisedSubject = new Subject>(); - private readonly reactionsSubject = new Subject< + private readonly handsRaisedSubject = new BehaviorSubject< + Record + >({}); + private readonly reactionsSubject = new BehaviorSubject< Record - >(); + >({}); public readonly handsRaised = this.handsRaisedSubject.asObservable(); public readonly reactions = this.reactionsSubject.asObservable(); diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index 6140793fc..7e687d311 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -15,7 +15,7 @@ import { createHandRaisedReaction, createRedaction, MockRoom, - MockRTCSession, + ReactionsMockRTCSession, TestReactionsWrapper, } from "./utils/testReactions"; @@ -55,7 +55,7 @@ const TestComponent: FC = () => { describe("useReactions", () => { test("starts with an empty list", () => { - const rtcSession = new MockRTCSession( + const rtcSession = new ReactionsMockRTCSession( new MockRoom(memberUserIdAlice), membership, ); @@ -68,7 +68,7 @@ describe("useReactions", () => { }); test("handles incoming raised hand", async () => { const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -81,7 +81,7 @@ describe("useReactions", () => { }); test("handles incoming unraised hand", async () => { const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -105,7 +105,7 @@ describe("useReactions", () => { const room = new MockRoom(memberUserIdAlice, [ createHandRaisedReaction(memberEventAlice, membership), ]); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -119,7 +119,7 @@ describe("useReactions", () => { const room = new MockRoom(memberUserIdAlice, [ createHandRaisedReaction(memberEventAlice, membership), ]); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -133,7 +133,7 @@ describe("useReactions", () => { const room = new MockRoom(memberUserIdAlice, [ createHandRaisedReaction(memberEventAlice, membership), ]); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -151,7 +151,7 @@ describe("useReactions", () => { const room = new MockRoom(memberUserIdAlice, [ createHandRaisedReaction(memberEventAlice, memberUserIdBob), ]); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( @@ -161,7 +161,7 @@ describe("useReactions", () => { }); test("ignores invalid sender for new event", async () => { const room = new MockRoom(memberUserIdAlice); - const rtcSession = new MockRTCSession(room, membership); + const rtcSession = new ReactionsMockRTCSession(room, membership); const { queryByRole } = render( diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 0470ab0c0..3ebdc8b66 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -349,6 +349,7 @@ export const ReactionsProvider = ({ ]); const toggleRaisedHand = useCallback(async () => { + console.log("toggleRaisedHand", myMembershipIdentifier); if (!myMembershipIdentifier) { return; } diff --git a/src/utils/test-fixtures.ts b/src/utils/test-fixtures.ts new file mode 100644 index 000000000..630f51a31 --- /dev/null +++ b/src/utils/test-fixtures.ts @@ -0,0 +1,17 @@ +import { + mockRtcMembership, + mockMatrixRoomMember, + mockRemoteParticipant, + mockLocalParticipant, +} from "./test"; + +export const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); +export const alice = mockMatrixRoomMember(aliceRtcMember); +export const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`; +export const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); + +export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); +export const local = mockMatrixRoomMember(localRtcMember); +export const localParticipant = mockLocalParticipant({ identity: "" }); + +export const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts new file mode 100644 index 000000000..a2a1354d5 --- /dev/null +++ b/src/utils/test-viewmodel.ts @@ -0,0 +1,71 @@ +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 { BehaviorSubject, of } from "rxjs"; +import { vitest } from "vitest"; +import { E2eeType } from "../e2ee/e2eeType"; +import { CallViewModel } from "../state/CallViewModel"; +import { mockLivekitRoom, mockMatrixRoom, MockRTCSession } from "./test"; +import { + aliceRtcMember, + aliceParticipant, + localParticipant, + localRtcMember, +} from "./test-fixtures"; +import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; + +/** + * Construct a basic CallViewModel to test components that make use of it. + * @param members + * @param initialRemoteRtcMemberships + * @returns + */ +export function getBasicCallViewModelEnvironment( + members: RoomMember[], + initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember], +): { + vm: CallViewModel; + remoteRtcMemberships: BehaviorSubject; + rtcSession: MockRTCSession; +} { + const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); + 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, + }); + + const remoteRtcMemberships = new BehaviorSubject( + initialRemoteRtcMemberships, + ); + + const rtcSession = new MockRTCSession( + matrixRoom, + localRtcMember, + ).withMemberships(remoteRtcMemberships); + + const vm = new CallViewModel( + rtcSession as unknown as MatrixRTCSession, + liveKitRoom, + { + kind: E2eeType.PER_PARTICIPANT, + }, + of(ConnectionState.Connected), + ); + return { vm, remoteRtcMemberships, rtcSession }; +} diff --git a/src/utils/test.ts b/src/utils/test.ts index 93a4c133b..a72551512 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -4,19 +4,21 @@ Copyright 2023, 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { map, Observable, of, SchedulerLike } from "rxjs"; +import { BehaviorSubject, map, Observable, of, SchedulerLike } from "rxjs"; import { RunHelpers, TestScheduler } from "rxjs/testing"; -import { expect, vi } from "vitest"; +import { expect, vi, vitest } from "vitest"; import { RoomMember, Room as MatrixRoom, MatrixEvent, Room, TypedEventEmitter, + MatrixClient, } from "matrix-js-sdk/src/matrix"; import { CallMembership, Focus, + MatrixRTCSession, MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap, SessionMembershipData, @@ -27,6 +29,7 @@ import { RemoteParticipant, RemoteTrackPublication, Room as LivekitRoom, + ConnectionState, } from "livekit-client"; import { @@ -36,6 +39,14 @@ import { import { E2eeType } from "../e2ee/e2eeType"; import { DEFAULT_CONFIG, ResolvedConfigOptions } from "../config/ConfigOptions"; import { Config } from "../config/Config"; +import { CallViewModel } from "../state/CallViewModel"; +import { + aliceParticipant, + aliceRtcMember, + localParticipant, + localRtcMember, +} from "./test-fixtures"; +import { randomUUID } from "crypto"; export function withFakeTimers(continuation: () => void): void { vi.useFakeTimers(); @@ -129,6 +140,7 @@ export function mockRtcMembership( }; const event = new MatrixEvent({ sender: typeof user === "string" ? user : user.userId, + event_id: `$-ev-${randomUUID()}:example.org`, }); return new CallMembership(event, data); } diff --git a/src/utils/testReactions.tsx b/src/utils/testReactions.tsx index fec3e859f..2b21ba340 100644 --- a/src/utils/testReactions.tsx +++ b/src/utils/testReactions.tsx @@ -27,53 +27,41 @@ import { ElementCallReactionEventType, ReactionOption, } from "../reactions"; - -export const TestReactionsWrapper = ({ - rtcSession, - children, -}: PropsWithChildren<{ - rtcSession: MockRTCSession | MatrixRTCSession; -}>): ReactNode => { - return ( - - {children} - - ); -}; - -export class MockRTCSession extends EventEmitter { - public memberships: { - sender: string; - eventId: string; - createdTs: () => Date; - }[]; - - public constructor( - public readonly room: MockRoom, - membership: Record, - ) { - super(); - this.memberships = Object.entries(membership).map(([eventId, sender]) => ({ - sender, - eventId, - createdTs: (): Date => new Date(), - })); - } - - public testRemoveMember(userId: string): void { - this.memberships = this.memberships.filter((u) => u.sender !== userId); - this.emit(MatrixRTCSessionEvent.MembershipsChanged); - } - - public testAddMember(sender: string): void { - this.memberships.push({ - sender, - eventId: `!fake-${randomUUID()}:event`, - createdTs: (): Date => new Date(), - }); - this.emit(MatrixRTCSessionEvent.MembershipsChanged); - } -} +import { MockRTCSession } from "./test"; + +// export class ReactionsMockRTCSession extends EventEmitter { +// public memberships: { +// sender: string; +// eventId: string; +// createdTs: () => Date; +// }[]; + +// public constructor( +// public readonly room: MockRoom, +// membership: Record, +// ) { +// super(); +// this.memberships = Object.entries(membership).map(([eventId, sender]) => ({ +// sender, +// eventId, +// createdTs: (): Date => new Date(), +// })); +// } + +// public testRemoveMember(userId: string): void { +// this.memberships = this.memberships.filter((u) => u.sender !== userId); +// this.emit(MatrixRTCSessionEvent.MembershipsChanged); +// } + +// public testAddMember(sender: string): void { +// this.memberships.push({ +// sender, +// eventId: `!fake-${randomUUID()}:event`, +// createdTs: (): Date => new Date(), +// }); +// this.emit(MatrixRTCSessionEvent.MembershipsChanged); +// } +// } export function createHandRaisedReaction( parentMemberEvent: string, @@ -126,6 +114,7 @@ export class MockRoom extends EventEmitter { public get client(): MatrixClient { return { getUserId: (): string => this.ownUserId, + getDeviceId: (): string => "ABCDEF", sendEvent: async ( ...props: Parameters ): ReturnType => {