diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 2eb2b5c36..a5e4b8a32 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -54,6 +54,7 @@ "options": "Options", "password": "Password", "profile": "Profile", + "raise_hand": "Raise hand", "settings": "Settings", "unencrypted": "Not encrypted", "username": "Username", diff --git a/src/App.tsx b/src/App.tsx index 8d841dba7..9f0f5f149 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ 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); @@ -82,27 +83,29 @@ export const App: FC = ({ history }) => { {loaded ? ( - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ) : ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 805b23137..1b40f3081 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -25,6 +25,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { useTranslation } from "react-i18next"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { MatrixError } from "matrix-js-sdk/src/matrix"; +import { WidgetApi } from "matrix-widget-api"; import { ErrorView } from "./FullScreenView"; import { fallbackICEServerAllowed, initClient } from "./utils/matrix"; @@ -36,6 +37,7 @@ import { import { translatedError } from "./TranslatedError"; import { useEventTarget } from "./useEvents"; import { Config } from "./config/Config"; +import { useReactions } from "./useReactions"; declare global { interface Window { @@ -144,6 +146,7 @@ interface Props { } export const ClientProvider: FC = ({ children }) => { + const { setSupportsReactions } = useReactions(); const history = useHistory(); // null = signed out, undefined = loading @@ -188,11 +191,11 @@ export const ClientProvider: FC = ({ children }) => { saveSession({ ...session, passwordlessUser: false }); setInitClientState({ - client: initClientState.client, + ...initClientState, passwordlessUser: false, }); }, - [initClientState?.client], + [initClientState], ); const setClient = useCallback( @@ -206,6 +209,7 @@ export const ClientProvider: FC = ({ children }) => { if (clientParams) { saveSession(clientParams.session); setInitClientState({ + widgetApi: null, client: clientParams.client, passwordlessUser: clientParams.session.passwordlessUser, }); @@ -309,12 +313,40 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } + if (initClientState.widgetApi) { + let supportsReactions = true; + + const reactSend = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.send.event:m.reaction", + ); + const redactSend = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.send.event:m.room.redaction", + ); + const reactRcv = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.receive.event:m.reaction", + ); + const redactRcv = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.receive.event:m.room.redaction", + ); + + if (!reactSend || !reactRcv || !redactSend || !redactRcv) { + supportsReactions = false; + } + + setSupportsReactions(supportsReactions); + if (!supportsReactions) { + logger.warn("Widget does not support reactions"); + } else { + logger.warn("Widget does support reactions"); + } + } + return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync]); + }, [initClientState, onSync, setSupportsReactions]); if (alreadyOpenedErr) { return ; @@ -326,6 +358,7 @@ export const ClientProvider: FC = ({ children }) => { }; type InitResult = { + widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -336,6 +369,7 @@ async function loadClient(): Promise { logger.log("Using a matryoshka client"); const client = await widget.client; return { + widgetApi: widget.api, client, passwordlessUser: false, }; @@ -364,6 +398,7 @@ async function loadClient(): Promise { try { const client = await initClient(initClientParams, true); return { + widgetApi: null, client, passwordlessUser, }; diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 5d747a03e..6012e5b9b 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -91,6 +91,39 @@ 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/room/InCallView.tsx b/src/room/InCallView.tsx index d50be3c9d..b4e7a4a7e 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -11,6 +11,10 @@ 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, @@ -30,6 +34,8 @@ 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"; @@ -39,6 +45,7 @@ import { MicButton, VideoButton, ShareScreenButton, + RaiseHandButton, SettingsButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; @@ -78,6 +85,8 @@ 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"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -168,6 +177,8 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { + const { supportsReactions } = useReactions(); + useWakeLock(); useEffect(() => { @@ -298,6 +309,73 @@ export const InCallView: FC = ({ [vm], ); + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const { raisedHands, setRaisedHands } = useReactions(); + const [reactionId, setReactionId] = useState(null); + const userId = client.getUserId()!; + const isHandRaised = raisedHands.includes(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 => { + const newRaisedHands = [...raisedHands]; + for (const m of memberships) { + const reaction = await getLastReactionEvent(m.eventId!); + if (reaction && reaction.getType() === EventType.Reaction) { + const content = reaction.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + newRaisedHands.push(m.sender!); + } + } + } + setRaisedHands(newRaisedHands); + }; + + void fetchReactions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + 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 === "🖐️") { + setRaisedHands([...raisedHands, event.getSender()!]); + } + } + if (event.getType() === EventType.RoomRedaction) { + // TODO: check target of redaction event + setRaisedHands(raisedHands.filter((id) => id !== event.getSender())); + } + }; + + client.on(MatrixRoomEvent.Timeline, handleReactionEvent); + client.on(MatrixRoomEvent.Redaction, handleReactionEvent); + + return (): void => { + client.on(MatrixRoomEvent.Timeline, handleReactionEvent); + client.off(MatrixRoomEvent.Redaction, handleReactionEvent); + }; + }, [client, raisedHands, setRaisedHands]); + useEffect(() => { widget?.api.transport .send( @@ -479,6 +557,52 @@ 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); + setRaisedHands(raisedHands.filter((id) => id !== 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); + setRaisedHands([...raisedHands, userId]); + logger.debug("Sent reaction event", reaction.event_id); + }) + .catch((e) => { + logger.error("Failed to send reaction event", e); + }); + } + }, [ + client, + isHandRaised, + memberships, + raisedHands, + reactionId, + rtcSession.room.roomId, + setRaisedHands, + setReactionId, + userId, + ]); + let footer: JSX.Element | null; if (noControls) { @@ -513,7 +637,16 @@ export const InCallView: FC = ({ />, ); } - buttons.push(); + if (supportsReactions) { + buttons.push( + , + ); + } + buttons.push(); } buttons.push( diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 4d518df45..ca0fa52c6 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -12,6 +12,7 @@ import { axe } from "vitest-axe"; import { GridTile } from "./GridTile"; import { withRemoteMedia } from "../utils/test"; +import { ReactionsProvider } from "../useReactions"; test("GridTile is accessible", async () => { await withRemoteMedia( @@ -26,14 +27,16 @@ test("GridTile is accessible", async () => { }, async (vm) => { const { container } = render( - {}} - targetWidth={300} - targetHeight={200} - showVideo - showSpeakingIndicators - />, + + {}} + targetWidth={300} + targetHeight={200} + showVideo + showSpeakingIndicators + /> + , ); expect(await axe(container)).toHaveNoViolations(); // Name should be visible diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index a46ff4725..959ae089b 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -44,6 +44,7 @@ import { import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; import { useLatest } from "../useLatest"; +import { useReactions } from "../useReactions"; interface TileProps { className?: string; @@ -90,6 +91,8 @@ const UserMediaTile = forwardRef( }, [vm], ); + const { raisedHands } = useReactions(); + const raisedHand = raisedHands.includes(vm.member?.userId ?? ""); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -144,6 +147,7 @@ const UserMediaTile = forwardRef( {menu} } + raisedHand={raisedHand} {...props} /> ); diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index adde1c7b8..2d8aba401 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -90,6 +90,22 @@ unconditionally select the container so we can use cqmin units */ place-items: start; } +.raisedHand { + margin: var(--cpd-space-2x); + padding: var(--cpd-space-2x); + padding-block: var(--cpd-space-2x); + color: var(--cpd-color-icon-secondary); + background-color: var(--cpd-color-icon-secondary); + display: flex; + align-items: center; + border-radius: var(--cpd-radius-pill-effect); + user-select: none; + overflow: hidden; + box-shadow: var(--small-drop-shadow); + box-sizing: border-box; + max-inline-size: 100%; +} + .nameTag { grid-area: nameTag; padding: var(--cpd-space-1x); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 42a056035..c369625b4 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -32,6 +32,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; + raisedHand: boolean; } export const MediaView = forwardRef( @@ -50,6 +51,7 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, + raisedHand, ...props }, ref, @@ -86,6 +88,22 @@ export const MediaView = forwardRef( )}
+ {raisedHand && ( +
+

+ ✋ +

+
+ )}
{nameTagLeadingIcon} diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index a37d9cc22..d01242c40 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -53,6 +53,7 @@ interface SpotlightItemBaseProps { unencryptedWarning: boolean; displayName: string; "aria-hidden"?: boolean; + raisedHand: boolean; } interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { @@ -157,6 +158,7 @@ const SpotlightItem = forwardRef( unencryptedWarning, displayName, "aria-hidden": ariaHidden, + raisedHand: false, }; return vm instanceof ScreenShareViewModel ? ( diff --git a/src/useReactions.tsx b/src/useReactions.tsx new file mode 100644 index 000000000..9cd920c60 --- /dev/null +++ b/src/useReactions.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2024 Milton Moura + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import React, { createContext, useContext, useState, ReactNode } from "react"; + +interface ReactionsContextType { + raisedHands: string[]; + setRaisedHands: React.Dispatch>; + supportsReactions: boolean; + setSupportsReactions: React.Dispatch>; +} + +const ReactionsContext = createContext( + undefined, +); + +export const useReactions = (): ReactionsContextType => { + const context = useContext(ReactionsContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +export const ReactionsProvider = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const [raisedHands, setRaisedHands] = useState([]); + const [supportsReactions, setSupportsReactions] = useState(true); + + return ( + + {children} + + ); +}; diff --git a/src/widget.ts b/src/widget.ts index f08968b65..9d3da4792 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -103,6 +103,8 @@ export const widget = ((): WidgetHelpers | null => { const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, + EventType.Reaction, + EventType.RoomRedaction, ]; const sendState = [