From 4501e670b24a7b8ad8a48712507b301488831a8c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 15:57:55 +0000 Subject: [PATCH] Refactor into own files. --- src/App.tsx | 45 ++++--- src/ClientContext.tsx | 24 ++-- src/button/Button.tsx | 33 ----- src/button/RaisedHandToggleButton.tsx | 143 +++++++++++++++++++++ src/button/index.ts | 1 + src/room/InCallView.tsx | 171 ++++---------------------- src/tile/GridTile.test.tsx | 12 +- src/useReactions.tsx | 96 ++++++++++++++- 8 files changed, 308 insertions(+), 217 deletions(-) create mode 100644 src/button/RaisedHandToggleButton.tsx diff --git a/src/App.tsx b/src/App.tsx index 9f0f5f14..8d841dba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,6 @@ import { Initializer } from "./initializer"; import { MediaDevicesProvider } from "./livekit/MediaDevicesContext"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; -import { ReactionsProvider } from "./useReactions"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -83,29 +82,27 @@ export const App: FC = ({ history }) => { {loaded ? ( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ) : ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 1b40f308..5a531c2a 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -37,7 +37,6 @@ import { import { translatedError } from "./TranslatedError"; import { useEventTarget } from "./useEvents"; import { Config } from "./config/Config"; -import { useReactions } from "./useReactions"; declare global { interface Window { @@ -54,6 +53,9 @@ export type ValidClientState = { // 'Disconnected' rather than 'connected' because it tracks specifically // whether the client is supposed to be connected but is not disconnected: boolean; + supportedFeatures: { + reactions: boolean; + }; setClient: (params?: SetClientParams) => void; }; @@ -146,7 +148,6 @@ interface Props { } export const ClientProvider: FC = ({ children }) => { - const { setSupportsReactions } = useReactions(); const history = useHistory(); // null = signed out, undefined = loading @@ -258,6 +259,7 @@ export const ClientProvider: FC = ({ children }) => { ); const [isDisconnected, setIsDisconnected] = useState(false); + const [supportsReactions, setSupportsReactions] = useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -281,6 +283,9 @@ export const ClientProvider: FC = ({ children }) => { authenticated, setClient, disconnected: isDisconnected, + supportedFeatures: { + reactions: supportsReactions, + }, }; }, [ alreadyOpenedErr, @@ -289,6 +294,7 @@ export const ClientProvider: FC = ({ children }) => { logout, setClient, isDisconnected, + supportsReactions, ]); const onSync = useCallback( @@ -314,8 +320,6 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - let supportsReactions = true; - const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -330,15 +334,13 @@ export const ClientProvider: FC = ({ children }) => { ); if (!reactSend || !reactRcv || !redactSend || !redactRcv) { - supportsReactions = false; - } - - setSupportsReactions(supportsReactions); - if (!supportsReactions) { logger.warn("Widget does not support reactions"); + setSupportsReactions(false); } else { - logger.warn("Widget does support reactions"); + setSupportsReactions(true); } + } else { + setSupportsReactions(true); } return (): void => { @@ -346,7 +348,7 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync, setSupportsReactions]); + }, [initClientState, onSync]); if (alreadyOpenedErr) { return ; diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 6012e5b9..5d747a03 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -91,39 +91,6 @@ export const ShareScreenButton: FC = ({ ); }; -interface RaiseHandButtonProps extends ComponentPropsWithoutRef<"button"> { - raised: boolean; -} -export const RaiseHandButton: FC = ({ - raised, - ...props -}) => { - const { t } = useTranslation(); - - return ( - - -

- ✋ -

-
-
- ); -}; - export const EndCallButton: FC> = ({ className, ...props diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx new file mode 100644 index 00000000..502f6240 --- /dev/null +++ b/src/button/RaisedHandToggleButton.tsx @@ -0,0 +1,143 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { Button as CpdButton, Tooltip } from "@vector-im/compound-web"; +import { + ComponentPropsWithoutRef, + FC, + ReactNode, + useCallback, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { logger } from "matrix-js-sdk/src/logger"; +import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +import { useReactions } from "../useReactions"; +import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; + +interface InnerButtonButtonProps extends ComponentPropsWithoutRef<"button"> { + raised: boolean; +} +const InnerButton: FC = ({ raised, ...props }) => { + const { t } = useTranslation(); + + return ( + + +

+ ✋ +

+
+
+ ); +}; + +interface RaisedHandToggleButton { + key: string; + rtcSession: MatrixRTCSession; + client: MatrixClient; +} + +export function RaiseHandToggleButton({ + key, + client, + rtcSession, +}: RaisedHandToggleButton): ReactNode { + const { + raisedHands, + removeRaisedHand, + addRaisedHand, + myReactionId, + setMyReactionId, + } = useReactions(); + const [busy, setBusy] = useState(false); + const userId = client.getUserId()!; + const isHandRaised = !!raisedHands[userId]; + const memberships = useMatrixRTCSessionMemberships(rtcSession); + + const toggleRaisedHand = useCallback(() => { + if (isHandRaised) { + if (myReactionId) { + setBusy(true); + client + .redactEvent(rtcSession.room.roomId, myReactionId) + .then(() => { + logger.debug("Redacted raise hand event"); + setMyReactionId(null); + removeRaisedHand(userId); + }) + .catch((e) => { + logger.error("Failed to redact reaction event", e); + }) + .finally(() => { + setBusy(false); + }); + } + } else { + const myMembership = memberships.find((m) => m.sender === userId); + if (!myMembership?.eventId) { + logger.error("Cannot find own membership event"); + return; + } + setBusy(true); + client + .sendEvent(rtcSession.room.roomId, EventType.Reaction, { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: myMembership.eventId, + key: "🖐️", + }, + }) + .then((reaction) => { + logger.debug("Sent raise hand event", reaction.event_id); + setMyReactionId(reaction.event_id); + addRaisedHand(userId, new Date()); + }) + .catch((e) => { + logger.error("Failed to send reaction event", e); + }) + .finally(() => { + setBusy(false); + }); + } + }, [ + client, + isHandRaised, + memberships, + myReactionId, + rtcSession.room.roomId, + addRaisedHand, + removeRaisedHand, + setMyReactionId, + userId, + ]); + + return ( + + ); +} diff --git a/src/button/index.ts b/src/button/index.ts index 178b58c0..e4e7cfad 100644 --- a/src/button/index.ts +++ b/src/button/index.ts @@ -7,3 +7,4 @@ Please see LICENSE in the repository root for full details. export * from "./Button"; export * from "./LinkButton"; +export * from "./RaisedHandToggleButton"; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8cd2b47a..90f7c840 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -11,10 +11,6 @@ import { useLocalParticipant, } from "@livekit/components-react"; import { ConnectionState, Room } from "livekit-client"; -import { - MatrixEvent, - RoomEvent as MatrixRoomEvent, -} from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, @@ -34,8 +30,6 @@ import classNames from "classnames"; import { BehaviorSubject, of } from "rxjs"; import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; -import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; -import { ReactionEventContent } from "matrix-js-sdk/src/types"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -45,8 +39,8 @@ import { MicButton, VideoButton, ShareScreenButton, - RaiseHandButton, SettingsButton, + RaiseHandToggleButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { useUrlParams } from "../UrlParams"; @@ -85,8 +79,7 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; -import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; -import { useReactions } from "../useReactions"; +import { ReactionsProvider, useReactions } from "../useReactions"; import handSound from "../res/sounds/raise-hand.ogg?url"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -140,12 +133,14 @@ export const ActiveCall: FC = (props) => { return ( - + + + ); }; @@ -178,7 +173,9 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { - const { supportsReactions } = useReactions(); + const { supportsReactions, raisedHandCount } = useReactions(); + const [previousRaisedHandCount, setPreviousRaisedHandCount] = + useState(raisedHandCount); useWakeLock(); @@ -310,87 +307,19 @@ export const InCallView: FC = ({ [vm], ); - const memberships = useMatrixRTCSessionMemberships(rtcSession); - const { raisedHands, addRaisedHand, removeRaisedHand } = useReactions(); - const [reactionId, setReactionId] = useState(null); - const userId = client.getUserId()!; - + // Play a sound when the raised hand count increases. const handRaisePlayer = useRef(null); - - const isHandRaised = !!raisedHands[userId]; - useEffect(() => { - const getLastReactionEvent = async ( - eventId: string, - ): Promise => { - return client - .relations( - rtcSession.room.roomId, - eventId, - RelationType.Annotation, - EventType.Reaction, - { - limit: 1, - }, - ) - .then((rels) => { - return rels.events.length > 0 ? rels.events[0] : undefined; - }); - }; - - const fetchReactions = async (): Promise => { - for (const m of memberships) { - if (!m.sender) { - continue; - } - const reaction = await getLastReactionEvent(m.eventId!); - if (reaction && reaction.getType() === EventType.Reaction) { - const content = reaction.getContent() as ReactionEventContent; - if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(m.sender, new Date(m.createdTs())); - if (m.sender === userId) { - setReactionId(m.eventId!); - } - } - } - } - }; - - void fetchReactions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - const handleReactionEvent = (event: MatrixEvent): void => { - const sender = event.getSender(); - if (!sender) { - // Weird, skip. - return; - } - if (event.getType() === EventType.Reaction) { - // TODO: check if target of reaction is a call membership event - const content = event.getContent() as ReactionEventContent; - if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(sender, new Date(event.localTimestamp)); - handRaisePlayer.current?.play().catch((ex) => { - logger.warn("Failed to play hand raise sound", ex); - }); - } - } - if (event.getType() === EventType.RoomRedaction && event.getSender()) { - // TODO: check target of redaction event - removeRaisedHand(sender); - } - }; - - client.on(MatrixRoomEvent.Timeline, handleReactionEvent); - client.on(MatrixRoomEvent.Redaction, handleReactionEvent); - - return (): void => { - client.on(MatrixRoomEvent.Timeline, handleReactionEvent); - client.off(MatrixRoomEvent.Redaction, handleReactionEvent); - }; - }, [client, raisedHands, addRaisedHand, removeRaisedHand]); + if (!handRaisePlayer.current) { + return; + } + if (previousRaisedHandCount < raisedHandCount) { + handRaisePlayer.current.play().catch((ex) => { + logger.warn("Failed to play raise hand sound", ex); + }); + } + setPreviousRaisedHandCount(raisedHandCount); + }, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]); useEffect(() => { widget?.api.transport @@ -573,52 +502,6 @@ export const InCallView: FC = ({ .catch(logger.error); }, [localParticipant, isScreenShareEnabled]); - const toggleRaisedHand = useCallback(() => { - if (isHandRaised) { - if (reactionId) { - client - .redactEvent(rtcSession.room.roomId, reactionId) - .then(() => { - setReactionId(null); - removeRaisedHand(userId); - logger.debug("Redacted reaction event"); - }) - .catch((e) => { - logger.error("Failed to redact reaction event", e); - }); - } - } else { - const m = memberships.filter((m) => m.sender === userId); - const eventId = m[0].eventId!; - client - .sendEvent(rtcSession.room.roomId, EventType.Reaction, { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: eventId, - key: "🖐️", - }, - }) - .then((reaction) => { - setReactionId(reaction.event_id); - addRaisedHand(userId, new Date()); - logger.debug("Sent reaction event", reaction.event_id); - }) - .catch((e) => { - logger.error("Failed to send reaction event", e); - }); - } - }, [ - client, - isHandRaised, - memberships, - reactionId, - rtcSession.room.roomId, - addRaisedHand, - removeRaisedHand, - setReactionId, - userId, - ]); - let footer: JSX.Element | null; if (noControls) { @@ -655,10 +538,10 @@ export const InCallView: FC = ({ } if (supportsReactions) { buttons.push( - , ); } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index ca0fa52c..9ba92afb 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -9,6 +9,7 @@ import { RemoteTrackPublication } from "livekit-client"; import { test, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { GridTile } from "./GridTile"; import { withRemoteMedia } from "../utils/test"; @@ -26,8 +27,17 @@ test("GridTile is accessible", async () => { ({}) as Partial as RemoteTrackPublication, }, async (vm) => { + const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + }, + memberships: [], + } as unknown as MatrixRTCSession; const { container } = render( - + {}} diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 78cca38f..72969a7b 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -5,20 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import React, { +import { + EventType, + MatrixEvent, + RelationType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; +import { ReactionEventContent } from "matrix-js-sdk/src/types"; +import { createContext, useContext, useState, ReactNode, useCallback, + useEffect, } from "react"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; interface ReactionsContextType { raisedHands: Record; + raisedHandCount: number; addRaisedHand: (userId: string, date: Date) => void; removeRaisedHand: (userId: string) => void; supportsReactions: boolean; - setSupportsReactions: React.Dispatch>; + myReactionId: string | null; + setMyReactionId: (id: string | null) => void; } const ReactionsContext = createContext( @@ -35,11 +49,19 @@ export const useReactions = (): ReactionsContextType => { export const ReactionsProvider = ({ children, + rtcSession, }: { children: ReactNode; + rtcSession: MatrixRTCSession; }): JSX.Element => { const [raisedHands, setRaisedHands] = useState>({}); - const [supportsReactions, setSupportsReactions] = useState(true); + const [myReactionId, setMyReactionId] = useState(null); + const [raisedHandCount, setRaisedHandCount] = useState(0); + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const clientState = useClientState(); + const supportsReactions = + clientState?.state === "valid" && clientState.supportedFeatures.reactions; + const room = rtcSession.room; const addRaisedHand = useCallback( (userId: string, time: Date) => { @@ -47,6 +69,7 @@ export const ReactionsProvider = ({ ...raisedHands, [userId]: time, }); + setRaisedHandCount(Object.keys(raisedHands).length + 1); }, [raisedHands], ); @@ -55,18 +78,83 @@ export const ReactionsProvider = ({ (userId: string) => { delete raisedHands[userId]; setRaisedHands(raisedHands); + setRaisedHandCount(Object.keys(raisedHands).length); }, [raisedHands], ); + // Load any existing reactions. + useEffect(() => { + const getLastReactionEvent = (eventId: string): MatrixEvent | undefined => { + const relations = room.relations.getChildEventsForEvent( + eventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.length > 0 ? allEvents[0] : undefined; + }; + + const fetchReactions = (): void => { + for (const m of memberships) { + if (!m.sender) { + continue; + } + const reaction = getLastReactionEvent(m.eventId!); + if (reaction && reaction.getType() === EventType.Reaction) { + const content = reaction.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(m.sender, new Date(m.createdTs())); + if (m.sender === room.client.getUserId()) { + setMyReactionId(m.eventId!); + } + } + } + } + }; + + void fetchReactions(); + }, [room, addRaisedHand, memberships]); + + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + const sender = event.getSender(); + if (!sender) { + // Skip any event without a sender. + return; + } + if (event.getType() === EventType.Reaction) { + // TODO: check if target of reaction is a call membership event + const content = event.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(sender, new Date(event.localTimestamp)); + } + } + if (event.getType() === EventType.RoomRedaction && event.getSender()) { + // TODO: check target of redaction event + removeRaisedHand(sender); + } + }; + + room.on(MatrixRoomEvent.Timeline, handleReactionEvent); + room.on(MatrixRoomEvent.Redaction, handleReactionEvent); + + return (): void => { + room.off(MatrixRoomEvent.Timeline, handleReactionEvent); + room.off(MatrixRoomEvent.Redaction, handleReactionEvent); + }; + }, [room, raisedHands, addRaisedHand, removeRaisedHand]); + return ( {children}