diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index bd9c115b..7faee4d8 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -18,7 +18,7 @@ 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 { ReactionsProvider } from "../useReactionsSender"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`; diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 6d75fa60..dd5b6a7d 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -27,7 +27,7 @@ import classNames from "classnames"; import { useObservableState } from "observable-hooks"; import { map } from "rxjs"; -import { useReactions } from "../useReactions"; +import { useReactionsSender } from "../useReactionsSender"; import styles from "./ReactionToggleButton.module.css"; import { type ReactionOption, @@ -175,7 +175,7 @@ export function ReactionToggleButton({ ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); - const { toggleRaisedHand, sendReaction } = useReactions(); + const { toggleRaisedHand, sendReaction } = useReactionsSender(); const [busy, setBusy] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); diff --git a/src/reactions/index.ts b/src/reactions/index.ts index f8253c81..53b26f20 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -181,3 +181,27 @@ export const ReactionSet: ReactionOption[] = [ }, }, ]; + +export interface RaisedHandInfo { + /** + * Call membership event that was reacted to. + */ + membershipEventId: string; + /** + * Event ID of the reaction itself. + */ + reactionEventId: string; + /** + * The time when the reaction was raised. + */ + time: Date; +} + +export interface ReactionInfo { + /** + * ... + */ + ttl: number; + + reactionOption: ReactionOption; +} diff --git a/src/useReactions.tsx b/src/reactions/useReactionsReader.ts similarity index 54% rename from src/useReactions.tsx rename to src/reactions/useReactionsReader.ts index 025ff073..6e25fd76 100644 --- a/src/useReactions.tsx +++ b/src/reactions/useReactionsReader.ts @@ -1,146 +1,62 @@ -/* -Copyright 2024 Milton Moura - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. -*/ - -import { - EventType, - type MatrixEvent, - RelationType, - RoomEvent as MatrixRoomEvent, - MatrixEventEvent, -} from "matrix-js-sdk/src/matrix"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; +import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; +import { useCallback, useEffect, useRef } from "react"; +import { useLatest } from "../useLatest"; +import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; import { type ReactionEventContent } from "matrix-js-sdk/src/types"; import { - createContext, - useContext, - useState, - type ReactNode, - useCallback, - useEffect, - useMemo, -} from "react"; -import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; -import { logger } from "matrix-js-sdk/src/logger"; - -import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; -import { useClientState } from "./ClientContext"; + RelationType, + EventType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; import { - type ECallReactionEventContent, ElementCallReactionEventType, + ECallReactionEventContent, GenericReaction, - type ReactionOption, ReactionSet, -} from "./reactions"; -import { useLatest } from "./useLatest"; -import { CallViewModel } from "./state/CallViewModel"; - -interface ReactionsContextType { - supportsReactions: boolean; - toggleRaisedHand: () => Promise; - sendReaction: (reaction: ReactionOption) => Promise; -} - -const ReactionsContext = createContext( - undefined, -); - -interface RaisedHandInfo { - /** - * Call membership event that was reacted to. - */ - membershipEventId: string; - /** - * Event ID of the reaction itself. - */ - reactionEventId: string; - /** - * The time when the reaction was raised. - */ - time: Date; -} + RaisedHandInfo, + ReactionInfo, +} from "."; +import { BehaviorSubject, Observable } from "rxjs"; const REACTION_ACTIVE_TIME_MS = 3000; -export const useReactions = (): ReactionsContextType => { - const context = useContext(ReactionsContext); - if (!context) { - throw new Error("useReactions must be used within a ReactionsProvider"); - } - return context; -}; - /** - * HS plan: - * Provider should publish new hand raised, reaction events to CallViewModel - * Provider should listen for new events from CVM + * 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>({}), + ); -/** - * Provider that handles raised hand reactions for a given `rtcSession`. - */ -export const ReactionsProvider = ({ - children, - rtcSession, - vm, -}: { - children: ReactNode; - rtcSession: MatrixRTCSession; - vm: CallViewModel; -}): JSX.Element => { - const [raisedHands, setRaisedHands] = useState< - Record - >({}); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const clientState = useClientState(); - const supportsReactions = - 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); - - useEffect(() => { - vm.updateReactions({ - raisedHands: Object.fromEntries( - Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), - ), - reactions, - }); - }, [memberships, raisedHands]); - - const myMembershipEvent = useMemo( - () => - memberships.find( - (m) => m.sender === myUserId && m.deviceId === myDeviceId, - )?.eventId, - [memberships, myUserId, myDeviceId], - ); - const myMembershipIdentifier = useMemo(() => { - const membership = memberships.find((m) => m.sender === myUserId); - return membership - ? `${membership.sender}:${membership.deviceId}` - : undefined; - }, [memberships, myUserId]); - - const [reactions, setReactions] = useState>( - {}, - ); + const latestRaisedHands = useLatest(raisedHandsSubject.current); + const room = rtcSession.room; const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { - setRaisedHands((prevRaisedHands) => ({ - ...prevRaisedHands, + raisedHandsSubject.current.next({ + ...raisedHandsSubject.current.value, [userId]: info, - })); + }); }, []); const removeRaisedHand = useCallback((userId: string) => { - setRaisedHands( - ({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands, + raisedHandsSubject.current.next( + Object.fromEntries( + Object.entries(raisedHandsSubject.current.value).filter( + ([uId]) => uId !== userId, + ), + ), ); }, []); @@ -166,7 +82,7 @@ export const ReactionsProvider = ({ }; // Remove any raised hands for users no longer joined to the call. - for (const identifier of Object.keys(raisedHands).filter( + for (const identifier of Object.keys(raisedHandsSubject).filter( (rhId) => !memberships.find((u) => u.sender == rhId), )) { removeRaisedHand(identifier); @@ -180,8 +96,9 @@ export const ReactionsProvider = ({ } const identifier = `${m.sender}:${m.deviceId}`; if ( - raisedHands[identifier] && - raisedHands[identifier].membershipEventId !== m.eventId + raisedHandsSubject.current.value[identifier] && + raisedHandsSubject.current.value[identifier].membershipEventId !== + m.eventId ) { // Membership event for sender has changed since the hand // was raised, reset. @@ -207,7 +124,7 @@ export const ReactionsProvider = ({ // This effect handles any *live* reaction/redactions in the room. useEffect(() => { - const reactionTimeouts = new Set(); + const reactionTimeouts = new Set(); const handleReactionEvent = (event: MatrixEvent): void => { // Decrypted events might come from a different room if (event.getRoomId() !== room.roomId) return; @@ -268,23 +185,29 @@ export const ReactionsProvider = ({ ...ReactionSet.find((r) => r.name === content.name), }; - setReactions((reactions) => { - 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( - ({ [identifier]: _unused, ...remaining }) => remaining, - ); - reactionTimeouts.delete(timeout); - }, REACTION_ACTIVE_TIME_MS); - reactionTimeouts.add(timeout); - return { - ...reactions, - [identifier]: reaction, - }; + const currentReactions = reactionsSubject.current.value; + if (currentReactions[identifier]) { + // We've still got a reaction from this user, ignore it to prevent spamming + return; + } + const timeout = globalThis.setTimeout(() => { + // Clear the reaction after some time. + reactionsSubject.current.next( + Object.fromEntries( + Object.entries(reactionsSubject.current.value).filter( + ([id]) => id !== identifier, + ), + ), + ); + reactionTimeouts.delete(timeout); + }, REACTION_ACTIVE_TIME_MS); + reactionTimeouts.add(timeout); + reactionsSubject.current.next({ + ...currentReactions, + [identifier]: { + reactionOption: reaction, + ttl: 0, + }, }); } else if (event.getType() === EventType.Reaction) { const content = event.getContent() as ReactionEventContent; @@ -340,7 +263,7 @@ export const ReactionsProvider = ({ room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); reactionTimeouts.forEach((t) => clearTimeout(t)); // If we're clearing timeouts, we also clear all reactions. - setReactions({}); + reactionsSubject.current.next({}); }; }, [ room, @@ -350,84 +273,8 @@ export const ReactionsProvider = ({ latestRaisedHands, ]); - const toggleRaisedHand = useCallback(async () => { - console.log("toggleRaisedHand", myMembershipIdentifier); - if (!myMembershipIdentifier) { - return; - } - const myReactionId = raisedHands[myMembershipIdentifier]?.reactionEventId; - - if (!myReactionId) { - try { - if (!myMembershipEvent) { - throw new Error("Cannot find own membership event"); - } - const reaction = await room.client.sendEvent( - rtcSession.room.roomId, - EventType.Reaction, - { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: myMembershipEvent, - key: "🖐️", - }, - }, - ); - logger.debug("Sent raise hand event", reaction.event_id); - } catch (ex) { - logger.error("Failed to send raised hand", ex); - } - } else { - try { - await room.client.redactEvent(rtcSession.room.roomId, myReactionId); - logger.debug("Redacted raise hand event"); - } catch (ex) { - logger.error("Failed to redact reaction event", myReactionId, ex); - throw ex; - } - } - }, [ - myMembershipEvent, - myMembershipIdentifier, - raisedHands, - rtcSession, - room, - ]); - - const sendReaction = useCallback( - async (reaction: ReactionOption) => { - if (!myMembershipIdentifier || reactions[myMembershipIdentifier]) { - // We're still reacting - return; - } - if (!myMembershipEvent) { - throw new Error("Cannot find own membership event"); - } - await room.client.sendEvent( - rtcSession.room.roomId, - ElementCallReactionEventType, - { - "m.relates_to": { - rel_type: RelationType.Reference, - event_id: myMembershipEvent, - }, - emoji: reaction.emoji, - name: reaction.name, - }, - ); - }, - [myMembershipEvent, reactions, room, myMembershipIdentifier, rtcSession], - ); - - return ( - - {children} - - ); -}; + return { + reactions: reactionsSubject.current.asObservable(), + raisedHands: raisedHandsSubject.current.asObservable(), + }; +} diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 1224240f..6237bcdf 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -130,40 +130,53 @@ test("plays no sound when the participant list is more than the maximum size", ( }); test("plays one sound when a hand is raised", () => { - const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { vm, handRaisedSubject } = getBasicCallViewModelEnvironment([ + local, + alice, + ]); render(); act(() => { - vm.updateReactions({ - raisedHands: { - [bobRtcMember.callId]: new Date(), + handRaisedSubject.next({ + [bobRtcMember.callId]: { + time: new Date(), + membershipEventId: "", + reactionEventId: "", }, - reactions: {}, }); }); expect(playSound).toBeCalledWith("raiseHand"); }); test("should not play a sound when a hand raise is retracted", () => { - const { vm } = getBasicCallViewModelEnvironment([local, alice]); + const { vm, handRaisedSubject } = getBasicCallViewModelEnvironment([ + local, + alice, + ]); render(); act(() => { - vm.updateReactions({ - raisedHands: { - ["foo"]: new Date(), - ["bar"]: new Date(), + handRaisedSubject.next({ + ["foo"]: { + time: new Date(), + membershipEventId: "", + reactionEventId: "", + }, + ["bar"]: { + time: new Date(), + membershipEventId: "", + reactionEventId: "", }, - reactions: {}, }); }); expect(playSound).toHaveBeenCalledTimes(2); act(() => { - vm.updateReactions({ - raisedHands: { - ["foo"]: new Date(), + handRaisedSubject.next({ + ["foo"]: { + time: new Date(), + membershipEventId: "", + reactionEventId: "", }, - reactions: {}, }); }); expect(playSound).toHaveBeenCalledTimes(2); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index dcf13f4b..1a191109 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -75,7 +75,7 @@ export function CallEventAudioRenderer({ void audioEngineRef.current?.playSound("left"); }); - const handRaisedSub = vm.handRaised.subscribe(() => { + const handRaisedSub = vm.newHandRaised.subscribe(() => { audioEngineRef.current?.playSound("raiseHand"); }); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 997d54a9..cf587fcd 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -55,7 +55,6 @@ import { InviteModal } from "./InviteModal"; import { useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { Link } from "../button/Link"; -import { ReactionsProvider } from "../useReactions"; import { useAudioContext } from "../useAudioContext"; import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { useLatest } from "../useLatest"; @@ -364,20 +363,18 @@ export const GroupCallView: FC = ({ return ( <> {shareModal} - - - + ); } else if (left && widget === null) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f5af9a41..9d5fb3af 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -83,7 +83,10 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel"; -import { ReactionsProvider, useReactions } from "../useReactions"; +import { + ReactionsSenderProvider, + useReactionsSender, +} from "../useReactionsSender"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { useSwitchCamera } from "./useSwitchCamera"; import { ReactionsOverlay } from "./ReactionsOverlay"; @@ -92,6 +95,7 @@ import { debugTileLayout as debugTileLayoutSetting, useSetting, } from "../settings/settings"; +import useReactionsReader from "../reactions/useReactionsReader"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -125,31 +129,41 @@ export const ActiveCall: FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const reader = useReactionsReader(props.rtcSession); + useEffect(() => { - if (livekitRoom !== undefined) { + if (livekitRoom !== undefined && reader !== undefined) { const vm = new CallViewModel( props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable, + reader.raisedHands, + reader.reactions, ); setVm(vm); return (): void => vm.destroy(); } - }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]); + }, [ + reader, + props.rtcSession, + livekitRoom, + props.e2eeSystem, + connStateObservable, + ]); if (livekitRoom === undefined || vm === null) return null; return ( - + - + ); }; @@ -182,7 +196,8 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { - const { supportsReactions, sendReaction, toggleRaisedHand } = useReactions(); + const { supportsReactions, sendReaction, toggleRaisedHand } = + useReactionsSender(); useWakeLock(); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 79dbdcfc..e067331e 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -88,7 +88,7 @@ import { oneOnOneLayout } from "./OneOnOneLayout"; import { pipLayout } from "./PipLayout"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { observeSpeaker } from "./observeSpeaker"; -import { ReactionOption } from "../reactions"; +import { RaisedHandInfo, ReactionInfo, ReactionOption } from "../reactions"; // How long we wait after a focus switch before showing the real participant // list again @@ -528,7 +528,7 @@ export class CallViewModel extends ViewModel { this.encryptionSystem, this.livekitRoom, this.handsRaised.pipe( - map((v) => v[matrixIdentifier] ?? undefined), + map((v) => v[matrixIdentifier].time ?? undefined), ), this.reactions.pipe( map((v) => v[matrixIdentifier] ?? undefined), @@ -1145,62 +1145,16 @@ export class CallViewModel extends ViewModel { this.scope.state(), ); - private readonly handsRaisedSubject = new BehaviorSubject< - { userId: string; date: Date | null }[] - >([]); - private readonly reactionsSubject = new BehaviorSubject< - { userId: string; reaction: ReactionOption; ttl: number }[] - >([]); - - public addHandRaised(userId: string, date: Date) { - this.handsRaisedSubject.next([{ userId, date }]); - } - - public removeHandRaised(userId: string, date: Date | null) { - this.handsRaisedSubject.next([{ userId, date }]); - } - - public addReaction(userId: string, reaction: ReactionOption, ttl: number) { - this.reactionsSubject.next([{ userId, reaction, ttl }]); - } - - public readonly reactions = this.reactionsSubject - .pipe( - scan< - { userId: string; reaction: ReactionOption; ttl: number }[], - Record - >((acc, value) => { - for (const { userId, reaction, ttl } of value) { - acc[userId] = { reaction, ttl }; - } - return acc; - }, {}), - ) - .pipe( - map((v) => - Object.fromEntries( - Object.entries(v).map(([a, { reaction }]) => [a, reaction]), - ), + public readonly reactions = this.reactionsSubject.pipe( + map((v) => + Object.fromEntries( + Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]), ), - ); - - public readonly handsRaised = this.handsRaisedSubject.pipe( - scan<{ userId: string; date: Date | null }[], Record>( - (acc, value) => { - for (const { userId, date } of value) { - if (date) { - acc[userId] = date; - } else { - delete acc[userId]; - } - } - console.log("handsRaised", acc); - return acc; - }, - {}, ), ); + public readonly handsRaised = this.handsRaisedSubject.pipe(); + /** * Emits an array of reactions that should be visible on the screen. */ @@ -1255,7 +1209,7 @@ export class CallViewModel extends ViewModel { * Emits an event every time a new hand is raised in * the call. */ - public readonly handRaised = this.handsRaised.pipe( + public readonly newHandRaised = this.handsRaised.pipe( map((v) => Object.keys(v).length), scan( (acc, newValue) => ({ @@ -1273,6 +1227,10 @@ export class CallViewModel extends ViewModel { private readonly livekitRoom: LivekitRoom, private readonly encryptionSystem: EncryptionSystem, private readonly connectionState: Observable, + private readonly handsRaisedSubject: Observable< + Record + >, + private readonly reactionsSubject: Observable>, ) { super(); } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index bca746a8..c5fa1183 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -15,7 +15,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSess import { GridTile } from "./GridTile"; import { mockRtcMembership, withRemoteMedia } from "../utils/test"; import { GridTileViewModel } from "../state/TileViewModel"; -import { ReactionsProvider } from "../useReactions"; +import { ReactionsProvider } from "../useReactionsSender"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index cd54b633..3e41646e 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -48,7 +48,7 @@ import { MediaView } from "./MediaView"; import { useLatest } from "../useLatest"; import { type GridTileViewModel } from "../state/TileViewModel"; import { useMergedRefs } from "../useMergedRefs"; -import { useReactions } from "../useReactions"; +import { useReactionsSender } from "../useReactionsSender"; interface TileProps { className?: string; @@ -81,7 +81,7 @@ const UserMediaTile = forwardRef( }, ref, ) => { - const { toggleRaisedHand } = useReactions(); + const { toggleRaisedHand } = useReactionsSender(); const { t } = useTranslation(); const video = useObservableEagerState(vm.video); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index 187c1b7a..76764fbb 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -10,7 +10,7 @@ import { act, type FC } from "react"; import { describe, expect, test } from "vitest"; import { RoomEvent } from "matrix-js-sdk/src/matrix"; -import { useReactions } from "./useReactions"; +import { useReactionsSender } from "./useReactionsSender"; import { createHandRaisedReaction, createRedaction, @@ -36,7 +36,7 @@ const membership: Record = { */ const TestComponent: FC = () => { - const { raisedHands } = useReactions(); + const { raisedHands } = useReactionsSender(); return (
    diff --git a/src/useReactionsSender.tsx b/src/useReactionsSender.tsx new file mode 100644 index 00000000..6b1eaaac --- /dev/null +++ b/src/useReactionsSender.tsx @@ -0,0 +1,176 @@ +/* +Copyright 2024 Milton Moura + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import { + createContext, + useContext, + type ReactNode, + useCallback, + useMemo, +} from "react"; +import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { logger } from "matrix-js-sdk/src/logger"; +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; +import { ElementCallReactionEventType, type ReactionOption } from "./reactions"; +import { CallViewModel } from "./state/CallViewModel"; +import { useObservableEagerState } from "observable-hooks"; +import { map } from "rxjs"; + +interface ReactionsSenderContextType { + supportsReactions: boolean; + toggleRaisedHand: () => Promise; + sendReaction: (reaction: ReactionOption) => Promise; +} + +const ReactionsSenderContext = createContext< + ReactionsSenderContextType | undefined +>(undefined); + +export const useReactionsSender = (): ReactionsSenderContextType => { + const context = useContext(ReactionsSenderContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +/** + * Provider that handles sending a reaction or hand raised event to a call. + */ +export const ReactionsSenderProvider = ({ + children, + rtcSession, + vm, +}: { + children: ReactNode; + rtcSession: MatrixRTCSession; + vm: CallViewModel; +}): JSX.Element => { + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const clientState = useClientState(); + const supportsReactions = + clientState?.state === "valid" && clientState.supportedFeatures.reactions; + const room = rtcSession.room; + const myUserId = room.client.getUserId(); + const myDeviceId = room.client.getDeviceId(); + + const myMembershipEvent = useMemo( + () => + memberships.find( + (m) => m.sender === myUserId && m.deviceId === myDeviceId, + )?.eventId, + [memberships, myUserId, myDeviceId], + ); + const myMembershipIdentifier = useMemo(() => { + const membership = memberships.find((m) => m.sender === myUserId); + return membership + ? `${membership.sender}:${membership.deviceId}` + : undefined; + }, [memberships, myUserId]); + + const myReaction = useObservableEagerState( + vm.reactions.pipe( + map((v) => + myMembershipIdentifier !== undefined + ? v[myMembershipIdentifier] + : undefined, + ), + ), + ); + + const myRaisedHand = useObservableEagerState( + vm.handsRaised.pipe( + map((v) => + myMembershipIdentifier !== undefined + ? v[myMembershipIdentifier] + : undefined, + ), + ), + ); + + const toggleRaisedHand = useCallback(async () => { + if (!myMembershipIdentifier) { + return; + } + const myReactionId = myRaisedHand?.reactionEventId; + + if (!myReactionId) { + try { + if (!myMembershipEvent) { + throw new Error("Cannot find own membership event"); + } + const reaction = await room.client.sendEvent( + rtcSession.room.roomId, + EventType.Reaction, + { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: myMembershipEvent, + key: "🖐️", + }, + }, + ); + logger.debug("Sent raise hand event", reaction.event_id); + } catch (ex) { + logger.error("Failed to send raised hand", ex); + } + } else { + try { + await room.client.redactEvent(rtcSession.room.roomId, myReactionId); + logger.debug("Redacted raise hand event"); + } catch (ex) { + logger.error("Failed to redact reaction event", myReactionId, ex); + throw ex; + } + } + }, [ + myMembershipEvent, + myMembershipIdentifier, + myRaisedHand, + rtcSession, + room, + ]); + + const sendReaction = useCallback( + async (reaction: ReactionOption) => { + if (!myMembershipIdentifier || myReaction) { + // We're still reacting + return; + } + if (!myMembershipEvent) { + throw new Error("Cannot find own membership event"); + } + await room.client.sendEvent( + rtcSession.room.roomId, + ElementCallReactionEventType, + { + "m.relates_to": { + rel_type: RelationType.Reference, + event_id: myMembershipEvent, + }, + emoji: reaction.emoji, + name: reaction.name, + }, + ); + }, + [myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession], + ); + + return ( + + {children} + + ); +}; diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index a2a1354d..2485f540 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -14,6 +14,7 @@ import { localRtcMember, } from "./test-fixtures"; import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container"; +import { RaisedHandInfo, ReactionInfo } from "../reactions"; /** * Construct a basic CallViewModel to test components that make use of it. @@ -28,6 +29,8 @@ export function getBasicCallViewModelEnvironment( vm: CallViewModel; remoteRtcMemberships: BehaviorSubject; rtcSession: MockRTCSession; + handRaisedSubject: BehaviorSubject>; + reactionsSubject: BehaviorSubject>; } { const matrixRoomMembers = new Map(members.map((p) => [p.userId, p])); const remoteParticipants = of([aliceParticipant]); @@ -54,6 +57,9 @@ export function getBasicCallViewModelEnvironment( initialRemoteRtcMemberships, ); + const handRaisedSubject = new BehaviorSubject({}); + const reactionsSubject = new BehaviorSubject({}); + const rtcSession = new MockRTCSession( matrixRoom, localRtcMember, @@ -66,6 +72,14 @@ export function getBasicCallViewModelEnvironment( kind: E2eeType.PER_PARTICIPANT, }, of(ConnectionState.Connected), + handRaisedSubject, + reactionsSubject, ); - return { vm, remoteRtcMemberships, rtcSession }; + return { + vm, + remoteRtcMemberships, + rtcSession, + handRaisedSubject, + reactionsSubject, + }; }