From 3aff86c2c48898d2b4a3fd42f63986a73fd0db4a Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 19 Dec 2024 10:54:40 +0000 Subject: [PATCH] Port useReactionsReader hook to ReactionsReader class. --- ...ader.test.tsx => ReactionsReader.test.tsx} | 18 +- src/reactions/ReactionsReader.ts | 336 ++++++++++++++++++ src/reactions/useReactionsReader.ts | 288 --------------- src/room/InCallView.tsx | 25 +- 4 files changed, 354 insertions(+), 313 deletions(-) rename src/reactions/{useReactionsReader.test.tsx => ReactionsReader.test.tsx} (97%) create mode 100644 src/reactions/ReactionsReader.ts delete mode 100644 src/reactions/useReactionsReader.ts diff --git a/src/reactions/useReactionsReader.test.tsx b/src/reactions/ReactionsReader.test.tsx similarity index 97% rename from src/reactions/useReactionsReader.test.tsx rename to src/reactions/ReactionsReader.test.tsx index c5e224d2..b66550f7 100644 --- a/src/reactions/useReactionsReader.test.tsx +++ b/src/reactions/ReactionsReader.test.tsx @@ -16,9 +16,7 @@ import { MatrixEventEvent, } from "matrix-js-sdk/src/matrix"; -import useReactionsReader, { - REACTION_ACTIVE_TIME_MS, -} from "./useReactionsReader"; +import { ReactionsReader, REACTION_ACTIVE_TIME_MS } from "./ReactionsReader"; import { alice, aliceRtcMember, @@ -39,7 +37,7 @@ test("handles a hand raised reaction", () => { const localTimestamp = new Date(); withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { raisedHands$ } = useReactionsReader( + const { raisedHands$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("ab", { @@ -87,7 +85,7 @@ test("handles a redaction", () => { const localTimestamp = new Date(); withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { raisedHands$ } = useReactionsReader( + const { raisedHands$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("abc", { @@ -150,7 +148,7 @@ test("handles waiting for event decryption", () => { const localTimestamp = new Date(); withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { raisedHands$ } = useReactionsReader( + const { raisedHands$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("abc", { @@ -219,7 +217,7 @@ test("hands rejecting events without a proper membership", () => { const localTimestamp = new Date(); withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { raisedHands$ } = useReactionsReader( + const { raisedHands$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("ab", { @@ -264,7 +262,7 @@ test("handles a reaction", () => { withTestScheduler(({ schedule, time, expectObservable }) => { renderHook(() => { - const { reactions$ } = useReactionsReader( + const { reactions$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule(`abc`, { @@ -322,7 +320,7 @@ test("ignores bad reaction events", () => { withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { reactions$ } = useReactionsReader( + const { reactions$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("ab", { @@ -446,7 +444,7 @@ test("that reactions cannot be spammed", () => { withTestScheduler(({ schedule, expectObservable }) => { renderHook(() => { - const { reactions$ } = useReactionsReader( + const { reactions$ } = new ReactionsReader( rtcSession as unknown as MatrixRTCSession, ); schedule("abcd", { diff --git a/src/reactions/ReactionsReader.ts b/src/reactions/ReactionsReader.ts new file mode 100644 index 00000000..0487cf4d --- /dev/null +++ b/src/reactions/ReactionsReader.ts @@ -0,0 +1,336 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { + type CallMembership, + MatrixRTCSessionEvent, + type MatrixRTCSession, +} from "matrix-js-sdk/src/matrixrtc"; +import { logger } from "matrix-js-sdk/src/logger"; +import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; +import { type ReactionEventContent } from "matrix-js-sdk/src/types"; +import { + RelationType, + EventType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; +import { BehaviorSubject, delay } from "rxjs"; + +import { + ElementCallReactionEventType, + type ECallReactionEventContent, + GenericReaction, + ReactionSet, + type RaisedHandInfo, + type ReactionInfo, +} from "."; + +export const REACTION_ACTIVE_TIME_MS = 3000; + +/** + * Listens for reactions from a RTCSession and populates subjects + * for consumption by the CallViewModel. + * @param rtcSession + */ +export class ReactionsReader { + private readonly raisedHandsSubject$ = new BehaviorSubject< + Record + >({}); + private readonly reactionsSubject$ = new BehaviorSubject< + Record + >({}); + + /** + * The latest set of raised hands. + */ + public readonly raisedHands$ = this.raisedHandsSubject$.asObservable(); + + /** + * The latest set of reactions. + */ + public readonly reactions$ = this.reactionsSubject$.asObservable(); + + public constructor(private readonly rtcSession: MatrixRTCSession) { + // Hide reactions after a given time. + this.reactionsSubject$ + .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; + } + this.reactionsSubject$.next(nextEntries); + }); + + this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent); + this.rtcSession.room.on( + MatrixRoomEvent.Redaction, + this.handleReactionEvent, + ); + this.rtcSession.room.client.on( + MatrixEventEvent.Decrypted, + this.handleReactionEvent, + ); + + // We listen for a local echo to get the real event ID, as timeline events + // may still be sending. + this.rtcSession.room.on( + MatrixRoomEvent.LocalEchoUpdated, + this.handleReactionEvent, + ); + + rtcSession.on( + MatrixRTCSessionEvent.MembershipsChanged, + this.onMembershipsChanged, + ); + + // Run this once to ensure we have fetched the state from the call. + this.onMembershipsChanged([]); + } + + /** + * Fetchest any hand wave reactions by the given sender on the given + * membership event. + * @param membershipEventId + * @param expectedSender + * @returns A MatrixEvent if one was found. + */ + private getLastReactionEvent( + membershipEventId: string, + expectedSender: string, + ): MatrixEvent | undefined { + const relations = this.rtcSession.room.relations.getChildEventsForEvent( + membershipEventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.find( + (reaction) => + reaction.event.sender === expectedSender && + reaction.getType() === EventType.Reaction && + reaction.getContent()?.["m.relates_to"]?.key === "🖐️", + ); + } + + /** + * Will remove any hand raises by old members, and look for any + * existing hand raises by new members. + * @param oldMemberships Any members who have left the call. + */ + private onMembershipsChanged = (oldMemberships: CallMembership[]): void => { + // Remove any raised hands for users no longer joined to the call. + for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter( + (rhId) => oldMemberships.find((u) => u.sender == rhId), + )) { + this.removeRaisedHand(identifier); + } + + // For each member in the call, check to see if a reaction has + // been raised and adjust. + for (const m of this.rtcSession.memberships) { + if (!m.sender || !m.eventId) { + continue; + } + const identifier = `${m.sender}:${m.deviceId}`; + if ( + this.raisedHandsSubject$.value[identifier] && + this.raisedHandsSubject$.value[identifier].membershipEventId !== + m.eventId + ) { + // Membership event for sender has changed since the hand + // was raised, reset. + this.removeRaisedHand(identifier); + } + const reaction = this.getLastReactionEvent(m.eventId, m.sender); + if (reaction) { + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + this.addRaisedHand(`${m.sender}:${m.deviceId}`, { + membershipEventId: m.eventId, + reactionEventId: eventId, + time: new Date(reaction.localTimestamp), + }); + } + } + }; + + /** + * Add a raised hand + * @param identifier A userId:deviceId combination. + * @param info The event information. + */ + private addRaisedHand(identifier: string, info: RaisedHandInfo): void { + this.raisedHandsSubject$.next({ + ...this.raisedHandsSubject$.value, + [identifier]: info, + }); + } + + /** + * Remove a raised hand + * @param identifier A userId:deviceId combination. + */ + private removeRaisedHand(identifier: string): void { + this.raisedHandsSubject$.next( + Object.fromEntries( + Object.entries(this.raisedHandsSubject$.value).filter( + ([uId]) => uId !== identifier, + ), + ), + ); + } + + /** + * Handle a new reaction event, validating it's contents and potentially + * updating the hand raise or reaction observers. + * @param event The incoming matrix event, which may or may not be decrypted. + */ + private handleReactionEvent = (event: MatrixEvent): void => { + const room = this.rtcSession.room; + // Decrypted events might come from a different room + if (event.getRoomId() !== room.roomId) return; + // Skip any events that are still sending. + if (event.isSending()) return; + + const sender = event.getSender(); + const reactionEventId = event.getId(); + // Skip any event without a sender or event ID. + if (!sender || !reactionEventId) return; + + room.client + .decryptEventIfNeeded(event) + .catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e)); + if (event.isBeingDecrypted() || event.isDecryptionFailure()) return; + + if (event.getType() === ElementCallReactionEventType) { + const content: ECallReactionEventContent = event.getContent(); + + const membershipEventId = content?.["m.relates_to"]?.event_id; + const membershipEvent = this.rtcSession.memberships.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 (!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}`); + return; + } + + const segment = new Intl.Segmenter(undefined, { + granularity: "grapheme", + }) + .segment(content.emoji) + [Symbol.iterator](); + const emoji = segment.next().value?.segment; + + if (!emoji?.trim()) { + logger.warn( + `Reaction had no emoji from ${reactionEventId} after splitting`, + ); + return; + } + + // One of our custom reactions + const reaction = { + ...GenericReaction, + emoji, + // If we don't find a reaction, we can fallback to the generic sound. + ...ReactionSet.find((r) => r.name === content.name), + }; + + const currentReactions = this.reactionsSubject$.value; + if (currentReactions[identifier]) { + // We've still got a reaction from this user, ignore it to prevent spamming + logger.warn(`Got reaction from ${identifier} but one is still playing`); + return; + } + this.reactionsSubject$.next({ + ...currentReactions, + [identifier]: { + reactionOption: reaction, + expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS), + }, + }); + } else if (event.getType() === EventType.Reaction) { + const content = event.getContent() as ReactionEventContent; + const membershipEventId = content["m.relates_to"].event_id; + + // Check to see if this reaction was made to a membership event (and the + // sender of the reaction matches the membership) + const membershipEvent = this.rtcSession.memberships.find( + (e) => e.eventId === membershipEventId && e.sender === sender, + ); + if (!membershipEvent) { + logger.warn( + `Reaction target was not a membership event for ${sender}, ignoring`, + ); + return; + } + + if (content?.["m.relates_to"].key === "🖐️") { + this.addRaisedHand( + `${membershipEvent.sender}:${membershipEvent.deviceId}`, + { + reactionEventId, + membershipEventId, + time: new Date(event.localTimestamp), + }, + ); + } + } else if (event.getType() === EventType.RoomRedaction) { + const targetEvent = event.event.redacts; + const targetUser = Object.entries(this.raisedHandsSubject$.value).find( + ([_u, r]) => r.reactionEventId === targetEvent, + )?.[0]; + if (!targetUser) { + // Reaction target was not for us, ignoring + return; + } + this.removeRaisedHand(targetUser); + } + }; + + /** + * Stop listening for events. + */ + public destroy(): void { + this.rtcSession.off( + MatrixRTCSessionEvent.MembershipsChanged, + this.onMembershipsChanged, + ); + this.rtcSession.room.off( + MatrixRoomEvent.Timeline, + this.handleReactionEvent, + ); + this.rtcSession.room.off( + MatrixRoomEvent.Redaction, + this.handleReactionEvent, + ); + this.rtcSession.room.client.off( + MatrixEventEvent.Decrypted, + this.handleReactionEvent, + ); + this.rtcSession.room.off( + MatrixRoomEvent.LocalEchoUpdated, + this.handleReactionEvent, + ); + } +} diff --git a/src/reactions/useReactionsReader.ts b/src/reactions/useReactionsReader.ts deleted file mode 100644 index be3f865d..00000000 --- a/src/reactions/useReactionsReader.ts +++ /dev/null @@ -1,288 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. -*/ - -import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; -import { useCallback, useEffect, useRef } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; -import { type ReactionEventContent } from "matrix-js-sdk/src/types"; -import { - RelationType, - EventType, - RoomEvent as MatrixRoomEvent, -} from "matrix-js-sdk/src/matrix"; -import { BehaviorSubject, delay, type Observable } from "rxjs"; - -import { - ElementCallReactionEventType, - type ECallReactionEventContent, - GenericReaction, - ReactionSet, - type RaisedHandInfo, - type ReactionInfo, -} from "."; -import { useLatest } from "../useLatest"; -import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; - -export const REACTION_ACTIVE_TIME_MS = 3000; - -/** - * Listens for reactions from a RTCSession and populates subjects - * for consumption by the CallViewModel. - * @param rtcSession - */ -export default function useReactionsReader(rtcSession: MatrixRTCSession): { - raisedHands$: Observable>; - reactions$: Observable>; -} { - const raisedHandsSubject$ = useRef( - new BehaviorSubject>({}), - ); - const reactionsSubject$ = useRef( - 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); - const room = rtcSession.room; - - const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { - raisedHandsSubject$.current.next({ - ...raisedHandsSubject$.current.value, - [userId]: info, - }); - }, []); - - const removeRaisedHand = useCallback((userId: string) => { - raisedHandsSubject$.current.next( - Object.fromEntries( - Object.entries(raisedHandsSubject$.current.value).filter( - ([uId]) => uId !== userId, - ), - ), - ); - }, []); - - // This effect will check the state whenever the membership of the session changes. - useEffect(() => { - // Fetches the first reaction for a given event. - const getLastReactionEvent = ( - eventId: string, - expectedSender: string, - ): MatrixEvent | undefined => { - const relations = room.relations.getChildEventsForEvent( - eventId, - RelationType.Annotation, - EventType.Reaction, - ); - const allEvents = relations?.getRelations() ?? []; - return allEvents.find( - (reaction) => - reaction.event.sender === expectedSender && - reaction.getType() === EventType.Reaction && - reaction.getContent()?.["m.relates_to"]?.key === "🖐️", - ); - }; - - // Remove any raised hands for users no longer joined to the call. - for (const identifier of Object.keys(raisedHandsSubject$).filter( - (rhId) => !memberships.find((u) => u.sender == rhId), - )) { - removeRaisedHand(identifier); - } - - // For each member in the call, check to see if a reaction has - // been raised and adjust. - for (const m of memberships) { - if (!m.sender || !m.eventId) { - continue; - } - const identifier = `${m.sender}:${m.deviceId}`; - if ( - raisedHandsSubject$.current.value[identifier] && - raisedHandsSubject$.current.value[identifier].membershipEventId !== - m.eventId - ) { - // Membership event for sender has changed since the hand - // was raised, reset. - removeRaisedHand(identifier); - } - const reaction = getLastReactionEvent(m.eventId, m.sender); - if (reaction) { - const eventId = reaction?.getId(); - if (!eventId) { - continue; - } - addRaisedHand(`${m.sender}:${m.deviceId}`, { - membershipEventId: m.eventId, - reactionEventId: eventId, - time: new Date(reaction.localTimestamp), - }); - } - } - // 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, addRaisedHand, removeRaisedHand]); - - // This effect handles any *live* reaction/redactions in the room. - useEffect(() => { - const handleReactionEvent = (event: MatrixEvent): void => { - // Decrypted events might come from a different room - if (event.getRoomId() !== room.roomId) return; - // Skip any events that are still sending. - if (event.isSending()) return; - - const sender = event.getSender(); - const reactionEventId = event.getId(); - // Skip any event without a sender or event ID. - if (!sender || !reactionEventId) return; - - room.client - .decryptEventIfNeeded(event) - .catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e)); - if (event.isBeingDecrypted() || event.isDecryptionFailure()) return; - - if (event.getType() === ElementCallReactionEventType) { - 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 (!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}`); - return; - } - - const segment = new Intl.Segmenter(undefined, { - granularity: "grapheme", - }) - .segment(content.emoji) - [Symbol.iterator](); - const emoji = segment.next().value?.segment; - - if (!emoji?.trim()) { - logger.warn( - `Reaction had no emoji from ${reactionEventId} after splitting`, - ); - return; - } - - // One of our custom reactions - const reaction = { - ...GenericReaction, - emoji, - // If we don't find a reaction, we can fallback to the generic sound. - ...ReactionSet.find((r) => r.name === content.name), - }; - - const currentReactions = reactionsSubject$.current.value; - if (currentReactions[identifier]) { - // We've still got a reaction from this user, ignore it to prevent spamming - logger.warn( - `Got reaction from ${identifier} but one is still playing`, - ); - return; - } - reactionsSubject$.current.next({ - ...currentReactions, - [identifier]: { - reactionOption: reaction, - expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS), - }, - }); - } else if (event.getType() === EventType.Reaction) { - const content = event.getContent() as ReactionEventContent; - const membershipEventId = content["m.relates_to"].event_id; - - // Check to see if this reaction was made to a membership event (and the - // sender of the reaction matches the membership) - 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`, - ); - return; - } - - if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand( - `${membershipEvent.sender}:${membershipEvent.deviceId}`, - { - reactionEventId, - membershipEventId, - time: new Date(event.localTimestamp), - }, - ); - } - } else if (event.getType() === EventType.RoomRedaction) { - const targetEvent = event.event.redacts; - const targetUser = Object.entries(latestRaisedHands.current.value).find( - ([_u, r]) => r.reactionEventId === targetEvent, - )?.[0]; - if (!targetUser) { - // Reaction target was not for us, ignoring - return; - } - removeRaisedHand(targetUser); - } - }; - - room.on(MatrixRoomEvent.Timeline, handleReactionEvent); - room.on(MatrixRoomEvent.Redaction, handleReactionEvent); - room.client.on(MatrixEventEvent.Decrypted, handleReactionEvent); - - // We listen for a local echo to get the real event ID, as timeline events - // may still be sending. - room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); - - return (): void => { - room.off(MatrixRoomEvent.Timeline, handleReactionEvent); - room.off(MatrixRoomEvent.Redaction, handleReactionEvent); - room.client.off(MatrixEventEvent.Decrypted, handleReactionEvent); - room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); - }; - }, [ - room, - addRaisedHand, - removeRaisedHand, - latestMemberships, - latestRaisedHands, - ]); - - return { - reactions$: reactionsSubject$.current.asObservable(), - raisedHands$: raisedHandsSubject$.current.asObservable(), - }; -} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d465410f..c7dcf00c 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -95,8 +95,7 @@ import { debugTileLayout as debugTileLayoutSetting, useSetting, } from "../settings/settings"; -import useReactionsReader from "../reactions/useReactionsReader"; -import { useLatest } from "../useLatest"; +import { ReactionsReader } from "../reactions/ReactionsReader"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -130,28 +129,24 @@ export const ActiveCall: FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const reader = useLatest(useReactionsReader(props.rtcSession)); - useEffect(() => { - if (livekitRoom !== undefined && reader !== undefined) { + if (livekitRoom !== undefined) { + const reactionsReader = new ReactionsReader(props.rtcSession); const vm = new CallViewModel( props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$, - reader.current.raisedHands$, - reader.current.reactions$, + reactionsReader.raisedHands$, + reactionsReader.reactions$, ); setVm(vm); - return (): void => vm.destroy(); + return (): void => { + vm.destroy(); + reactionsReader.destroy(); + }; } - }, [ - props.rtcSession, - livekitRoom, - props.e2eeSystem, - connStateObservable$, - reader, - ]); + }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]); if (livekitRoom === undefined || vm === null) return null;