diff --git a/package.json b/package.json index 85ab2ec8..860d64a6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@babel/preset-env": "^7.22.20", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", + "@formatjs/intl-durationformat": "^0.6.1", "@livekit/components-core": "^0.11.0", "@livekit/components-react": "^2.0.0", "@opentelemetry/api": "^1.4.0", diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index fa4066ad..02dd7740 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -53,7 +53,9 @@ "next": "Next", "options": "Options", "password": "Password", + "preferences": "Preferences", "profile": "Profile", + "raise_hand": "Raise hand", "settings": "Settings", "unencrypted": "Not encrypted", "username": "Username", @@ -145,6 +147,10 @@ "feedback_tab_title": "Feedback", "more_tab_title": "More", "opt_in_description": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", + "preferences_tab_body": "Here you can configure extra options for an improved experience", + "preferences_tab_h4": "Preferences", + "preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand", + "preferences_tab_show_hand_raised_timer_label": "Show hand raise duration", "speaker_device_selection_label": "Speaker" }, "star_rating_input_label_one": "{{count}} stars", diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 805b2313..5a531c2a 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"; @@ -52,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; }; @@ -188,11 +192,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 +210,7 @@ export const ClientProvider: FC = ({ children }) => { if (clientParams) { saveSession(clientParams.session); setInitClientState({ + widgetApi: null, client: clientParams.client, passwordlessUser: clientParams.session.passwordlessUser, }); @@ -254,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) { @@ -277,6 +283,9 @@ export const ClientProvider: FC = ({ children }) => { authenticated, setClient, disconnected: isDisconnected, + supportedFeatures: { + reactions: supportsReactions, + }, }; }, [ alreadyOpenedErr, @@ -285,6 +294,7 @@ export const ClientProvider: FC = ({ children }) => { logout, setClient, isDisconnected, + supportsReactions, ]); const onSync = useCallback( @@ -309,6 +319,30 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } + if (initClientState.widgetApi) { + 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) { + logger.warn("Widget does not support reactions"); + setSupportsReactions(false); + } else { + setSupportsReactions(true); + } + } else { + setSupportsReactions(true); + } + return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); @@ -326,6 +360,7 @@ export const ClientProvider: FC = ({ children }) => { }; type InitResult = { + widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -336,6 +371,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 +400,7 @@ async function loadClient(): Promise { try { const client = await initClient(initClientParams, true); return { + widgetApi: null, client, passwordlessUser, }; diff --git a/src/Modal.module.css b/src/Modal.module.css index b69a1071..fae3a6fb 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -12,7 +12,7 @@ Please see LICENSE in the repository root for full details. .dialog { box-sizing: border-box; - inline-size: 520px; + inline-size: 580px; max-inline-size: 90%; max-block-size: 600px; } diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx new file mode 100644 index 00000000..277817de --- /dev/null +++ b/src/button/RaisedHandToggleButton.tsx @@ -0,0 +1,133 @@ +/* +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 InnerButtonProps extends ComponentPropsWithoutRef<"button"> { + raised: boolean; +} + +const InnerButton: FC = ({ raised, ...props }) => { + const { t } = useTranslation(); + + return ( + + +

+ ✋ +

+
+
+ ); +}; + +interface RaisedHandToggleButtonProps { + rtcSession: MatrixRTCSession; + client: MatrixClient; +} + +export function RaiseHandToggleButton({ + client, + rtcSession, +}: RaisedHandToggleButtonProps): ReactNode { + const { raisedHands, myReactionId } = useReactions(); + const [busy, setBusy] = useState(false); + const userId = client.getUserId()!; + const isHandRaised = !!raisedHands[userId]; + const memberships = useMatrixRTCSessionMemberships(rtcSession); + + const toggleRaisedHand = useCallback(() => { + const raiseHand = async (): Promise => { + if (isHandRaised) { + if (!myReactionId) { + logger.warn(`Hand raised but no reaction event to redact!`); + return; + } + try { + setBusy(true); + await client.redactEvent(rtcSession.room.roomId, myReactionId); + logger.debug("Redacted raise hand event"); + } catch (ex) { + logger.error("Failed to redact reaction event", myReactionId, ex); + } finally { + setBusy(false); + } + } else { + const myMembership = memberships.find((m) => m.sender === userId); + if (!myMembership?.eventId) { + logger.error("Cannot find own membership event"); + return; + } + const parentEventId = myMembership.eventId; + try { + setBusy(true); + const reaction = await client.sendEvent( + rtcSession.room.roomId, + EventType.Reaction, + { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: parentEventId, + key: "🖐️", + }, + }, + ); + logger.debug("Sent raise hand event", reaction.event_id); + } catch (ex) { + logger.error("Failed to send reaction event", ex); + } finally { + setBusy(false); + } + } + }; + + void raiseHand(); + }, [ + client, + isHandRaised, + memberships, + myReactionId, + rtcSession.room.roomId, + 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/reactions/RaisedHandIndicator.module.css b/src/reactions/RaisedHandIndicator.module.css new file mode 100644 index 00000000..4c274374 --- /dev/null +++ b/src/reactions/RaisedHandIndicator.module.css @@ -0,0 +1,52 @@ +.raisedHandWidget { + display: flex; + background-color: var(--cpd-color-bg-subtle-primary); + border-radius: var(--cpd-radius-pill-effect); + color: var(--cpd-color-icon-secondary); +} + +.raisedHandWidget > p { + padding: none; + margin-top: auto; + margin-bottom: auto; + width: 4em; +} + +.raisedHandWidgetLarge > p { + padding: var(--cpd-space-2x); +} + +.raisedHandLarge { + margin: var(--cpd-space-2x); + padding: var(--cpd-space-2x); + padding-block: var(--cpd-space-2x); +} + +.raisedHand { + margin: var(--cpd-space-1x); + 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%; + max-width: fit-content; +} + +.raisedHand > span { + width: var(--cpd-space-6x); + height: var(--cpd-space-6x); + display: inline-block; + text-align: center; + font-size: 16px; +} + +.raisedHandLarge > span { + width: var(--cpd-space-8x); + height: var(--cpd-space-8x); + font-size: 22px; +} diff --git a/src/reactions/RaisedHandIndicator.test.tsx b/src/reactions/RaisedHandIndicator.test.tsx new file mode 100644 index 00000000..22a665a7 --- /dev/null +++ b/src/reactions/RaisedHandIndicator.test.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test } from "vitest"; +import { render, configure } from "@testing-library/react"; + +import { RaisedHandIndicator } from "./RaisedHandIndicator"; + +configure({ + defaultHidden: true, +}); + +describe("RaisedHandIndicator", () => { + test("renders nothing when no hand has been raised", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + test("renders an indicator when a hand has been raised", () => { + const dateTime = new Date(); + const { container } = render( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); + test("renders an indicator when a hand has been raised with the expected time", () => { + const dateTime = new Date(new Date().getTime() - 60000); + const { container } = render( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); + test("renders a smaller indicator when minature is specified", () => { + const dateTime = new Date(); + const { container } = render( + , + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx new file mode 100644 index 00000000..19ddaf46 --- /dev/null +++ b/src/reactions/RaisedHandIndicator.tsx @@ -0,0 +1,77 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ReactNode, useEffect, useState } from "react"; +import classNames from "classnames"; +import "@formatjs/intl-durationformat/polyfill"; +import { DurationFormat } from "@formatjs/intl-durationformat"; + +import styles from "./RaisedHandIndicator.module.css"; + +const durationFormatter = new DurationFormat(undefined, { + minutesDisplay: "always", + secondsDisplay: "always", + hoursDisplay: "auto", + style: "digital", +}); + +export function RaisedHandIndicator({ + raisedHandTime, + minature, + showTimer, +}: { + raisedHandTime?: Date; + minature?: boolean; + showTimer?: boolean; +}): ReactNode { + const [raisedHandDuration, setRaisedHandDuration] = useState(""); + + // This effect creates a simple timer effect. + useEffect(() => { + if (!raisedHandTime || !showTimer) { + return; + } + + const calculateTime = (): void => { + const totalSeconds = Math.ceil( + (new Date().getTime() - raisedHandTime.getTime()) / 1000, + ); + setRaisedHandDuration( + durationFormatter.format({ + seconds: totalSeconds % 60, + minutes: Math.floor(totalSeconds / 60), + }), + ); + }; + calculateTime(); + const to = setInterval(calculateTime, 1000); + return (): void => clearInterval(to); + }, [setRaisedHandDuration, raisedHandTime, showTimer]); + + if (raisedHandTime) { + return ( +
+
+ + ✋ + +
+ {showTimer &&

{raisedHandDuration}

} +
+ ); + } + + return null; +} diff --git a/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap new file mode 100644 index 00000000..503631dc --- /dev/null +++ b/src/reactions/__snapshots__/RaisedHandIndicator.test.tsx.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RaisedHandIndicator > renders a smaller indicator when minature is specified 1`] = ` +
+
+ + ✋ + +
+

+ 00:01 +

+
+`; + +exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = ` +
+
+ + ✋ + +
+

+ 00:01 +

+
+`; + +exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = ` +
+
+ + ✋ + +
+

+ 01:01 +

+
+`; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b754c696..9492b2f0 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -19,6 +19,7 @@ import { TouchEvent, forwardRef, useCallback, + useDeferredValue, useEffect, useMemo, useRef, @@ -40,6 +41,7 @@ import { VideoButton, ShareScreenButton, SettingsButton, + RaiseHandToggleButton, SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; @@ -79,6 +81,9 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; +import { ReactionsProvider, useReactions } from "../useReactions"; +import handSoundOgg from "../sound/raise_hand.ogg?url"; +import handSoundMp3 from "../sound/raise_hand.mp3?url"; import { useSwitchCamera } from "./useSwitchCamera"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -132,12 +137,14 @@ export const ActiveCall: FC = (props) => { return ( - + + + ); }; @@ -170,6 +177,13 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { + const { supportsReactions, raisedHands } = useReactions(); + const raisedHandCount = useMemo( + () => Object.keys(raisedHands).length, + [raisedHands], + ); + const previousRaisedHandCount = useDeferredValue(raisedHandCount); + useWakeLock(); useEffect(() => { @@ -308,6 +322,19 @@ export const InCallView: FC = ({ [vm], ); + // Play a sound when the raised hand count increases. + const handRaisePlayer = useRef(null); + useEffect(() => { + if (!handRaisePlayer.current) { + return; + } + if (previousRaisedHandCount < raisedHandCount) { + handRaisePlayer.current.play().catch((ex) => { + logger.warn("Failed to play raise hand sound", ex); + }); + } + }, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]); + useEffect(() => { widget?.api.transport .send( @@ -527,6 +554,15 @@ export const InCallView: FC = ({ />, ); } + if (supportsReactions) { + buttons.push( + , + ); + } buttons.push(); } @@ -608,6 +644,10 @@ export const InCallView: FC = ({ ))} {renderContent()} + {footer} {!noControls && } { + const { t } = useTranslation(); + const [showHandRaisedTimer, setShowHandRaisedTimer] = useSetting( + showHandRaisedTimerSetting, + ); + + const onChangeSetting = useCallback( + (e: ChangeEvent) => { + setShowHandRaisedTimer(e.target.checked); + }, + [setShowHandRaisedTimer], + ); + + return ( +
+

{t("settings.preferences_tab_h4")}

+ {t("settings.preferences_tab_body")} + + + +
+ ); +}; diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index c4ba24d1..db702ef8 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -30,11 +30,13 @@ import { useOptInAnalytics, } from "./settings"; import { isFirefox } from "../Platform"; +import { PreferencesSettingsTab } from "./PreferencesSettingsTab"; type SettingsTab = | "audio" | "video" | "profile" + | "preferences" | "feedback" | "more" | "developer"; @@ -135,6 +137,12 @@ export const SettingsModal: FC = ({ content: generateDeviceSelection(devices.videoInput, t("common.camera")), }; + const preferencesTab: Tab = { + key: "preferences", + name: t("common.preferences"), + content: , + }; + const profileTab: Tab = { key: "profile", name: t("common.profile"), @@ -234,7 +242,7 @@ export const SettingsModal: FC = ({ const tabs = [audioTab, videoTab]; if (widget === null) tabs.push(profileTab); - tabs.push(feedbackTab, moreTab); + tabs.push(preferencesTab, feedbackTab, moreTab); if (developerSettingsTab) tabs.push(developerTab); return ( diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 61471bff..109a882b 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -85,4 +85,9 @@ export const videoInput = new Setting( undefined, ); +export const showHandRaisedTimer = new Setting( + "hand-raised-show-timer", + false, +); + export const alwaysShowSelf = new Setting("always-show-self", true); diff --git a/src/sound/raise_hand.mp3 b/src/sound/raise_hand.mp3 new file mode 100644 index 00000000..345df594 Binary files /dev/null and b/src/sound/raise_hand.mp3 differ diff --git a/src/sound/raise_hand.ogg b/src/sound/raise_hand.ogg new file mode 100644 index 00000000..9c367506 Binary files /dev/null and b/src/sound/raise_hand.ogg differ diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index 7416e2e4..bb068512 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -21,6 +21,15 @@ borders don't support gradients */ transition: opacity ease 0.15s; inset: calc(-1 * var(--cpd-border-width-4)); border-radius: var(--cpd-space-5x); + background-blend-mode: overlay, normal; +} + +.tile.speaking { + /* !important because speaking border should take priority over hover */ + outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; +} + +.tile.speaking::before { background: linear-gradient( 119deg, rgba(13, 92, 189, 0.7) 0%, @@ -31,15 +40,25 @@ borders don't support gradients */ rgba(13, 92, 189, 0.9) 0%, rgba(13, 189, 168, 0.9) 100% ); - background-blend-mode: overlay, normal; + opacity: 1; } -.tile.speaking { - /* !important because speaking border should take priority over hover */ - outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important; +.tile.handRaised { + /* !important because hand raised border should take priority over hover */ + outline: var(--cpd-border-width-2) solid var(--cpd-color-bg-canvas-default) !important; } -.tile.speaking::before { +.tile.handRaised::before { + background: linear-gradient( + 119deg, + var(--cpd-color-yellow-1200) 0%, + var(--cpd-color-yellow-900) 100% + ), + linear-gradient( + 180deg, + var(--cpd-color-yellow-1200) 0%, + var(--cpd-color-yellow-900) 100% + ); opacity: 1; } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 4d518df4..0bf6cab8 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -9,9 +9,11 @@ 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"; +import { ReactionsProvider } from "../useReactions"; test("GridTile is accessible", async () => { await withRemoteMedia( @@ -25,15 +27,29 @@ test("GridTile is accessible", async () => { ({}) as Partial as RemoteTrackPublication, }, async (vm) => { + const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + client: { + getUserId: () => null, + }, + }, + memberships: [], + } as unknown as MatrixRTCSession; 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 1eb0b933..3675e9a7 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,7 @@ const UserMediaTile = forwardRef( }, [vm], ); + const { raisedHands } = useReactions(); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -107,6 +109,10 @@ const UserMediaTile = forwardRef( ); + const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; + + const showSpeaking = showSpeakingIndicators && speaking; + const tile = ( ( videoEnabled={videoEnabled && showVideo} videoFit={cropVideo ? "cover" : "contain"} className={classNames(className, styles.tile, { - [styles.speaking]: showSpeakingIndicators && speaking, + [styles.speaking]: showSpeaking, + [styles.handRaised]: !showSpeaking && !!handRaised, })} nameTagLeadingIcon={ ( {menu} } + raisedHandTime={handRaised} {...props} /> ); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 42a05603..d8b03dc9 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -17,6 +17,8 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; +import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; +import { showHandRaisedTimer, useSetting } from "../settings/settings"; interface Props extends ComponentProps { className?: string; @@ -32,6 +34,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; + raisedHandTime?: Date; } export const MediaView = forwardRef( @@ -50,11 +53,15 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, + raisedHandTime, ...props }, ref, ) => { const { t } = useTranslation(); + const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); + + const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); return ( ( @@ -86,6 +93,11 @@ export const MediaView = forwardRef( )}
+
{nameTagLeadingIcon} diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx new file mode 100644 index 00000000..79caeb0a --- /dev/null +++ b/src/useReactions.test.tsx @@ -0,0 +1,268 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { act, render } from "@testing-library/react"; +import { FC, ReactNode } from "react"; +import { describe, expect, test } from "vitest"; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { + EventTimeline, + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomEvent, +} from "matrix-js-sdk/src/matrix"; +import EventEmitter from "events"; +import { randomUUID } from "crypto"; + +import { ReactionsProvider, useReactions } from "./useReactions"; + +/** + * Test explanation. + * This test suite checks that the useReactions hook appropriately reacts + * to new reactions, redactions and membership changesin the room. There is + * a large amount of test structure used to construct a mock environment. + */ + +const memberUserIdAlice = "@alice:example.org"; +const memberEventAlice = "$membership-alice:example.org"; +const memberUserIdBob = "@bob:example.org"; +const memberEventBob = "$membership-bob:example.org"; + +const membership: Record = { + [memberEventAlice]: memberUserIdAlice, + [memberEventBob]: memberUserIdBob, + "$membership-charlie:example.org": "@charlie:example.org", +}; + +const TestComponent: FC = () => { + const { raisedHands, myReactionId } = useReactions(); + return ( +
+
    + {Object.entries(raisedHands).map(([userId, date]) => ( +
  • + {userId} + +
  • + ))} +
+

{myReactionId ? "Local reaction" : "No local reaction"}

+
+ ); +}; + +const TestComponentWrapper = ({ + rtcSession, +}: { + rtcSession: MockRTCSession; +}): ReactNode => { + return ( + + + + ); +}; + +export class MockRTCSession extends EventEmitter { + public memberships = Object.entries(membership).map(([eventId, sender]) => ({ + sender, + eventId, + createdTs: (): Date => new Date(), + })); + + public constructor(public readonly room: MockRoom) { + super(); + } + + 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); + } +} + +function createReaction( + parentMemberEvent: string, + overridenSender?: string, +): MatrixEvent { + return new MatrixEvent({ + sender: overridenSender ?? membership[parentMemberEvent], + type: EventType.Reaction, + origin_server_ts: new Date().getTime(), + content: { + "m.relates_to": { + key: "🖐️", + event_id: parentMemberEvent, + }, + }, + event_id: randomUUID(), + }); +} + +function createRedaction(sender: string, reactionEventId: string): MatrixEvent { + return new MatrixEvent({ + sender, + type: EventType.RoomRedaction, + origin_server_ts: new Date().getTime(), + redacts: reactionEventId, + content: {}, + event_id: randomUUID(), + }); +} + +export class MockRoom extends EventEmitter { + public constructor(private readonly existingRelations: MatrixEvent[] = []) { + super(); + } + + public get client(): MatrixClient { + return { + getUserId: (): string => memberUserIdAlice, + } as unknown as MatrixClient; + } + + public get relations(): Room["relations"] { + return { + getChildEventsForEvent: (membershipEventId: string) => ({ + getRelations: (): MatrixEvent[] => { + return this.existingRelations.filter( + (r) => + r.getContent()["m.relates_to"]?.event_id === membershipEventId, + ); + }, + }), + } as unknown as Room["relations"]; + } + + public testSendReaction( + parentMemberEvent: string, + overridenSender?: string, + ): string { + const evt = createReaction(parentMemberEvent, overridenSender); + this.emit(RoomEvent.Timeline, evt, this, undefined, false, { + timeline: new EventTimeline(new EventTimelineSet(undefined)), + }); + return evt.getId()!; + } +} + +describe("useReactions", () => { + test("starts with an empty list", () => { + const rtcSession = new MockRTCSession(new MockRoom()); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles own raised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByText } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByText("Local reaction")).toBeTruthy(); + }); + test("handles incoming raised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => room.testSendReaction(memberEventBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(2); + }); + test("handles incoming unraised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + const reactionEventId = await act(() => + room.testSendReaction(memberEventAlice), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => + room.emit( + RoomEvent.Redaction, + createRedaction(memberUserIdAlice, reactionEventId), + room, + undefined, + ), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles loading prior raised hand events", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + }); + // If the membership event changes for a user, we want to remove + // the raised hand event. + test("will remove reaction when a member leaves the call", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + act(() => rtcSession.testRemoveMember(memberUserIdAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("will remove reaction when a member joins via a new event", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + // Simulate leaving and rejoining + act(() => { + rtcSession.testRemoveMember(memberUserIdAlice); + rtcSession.testAddMember(memberUserIdAlice); + }); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for historic event", () => { + const room = new MockRoom([ + createReaction(memberEventAlice, memberUserIdBob), + ]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for new event", async () => { + const room = new MockRoom([]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); +}); diff --git a/src/useReactions.tsx b/src/useReactions.tsx new file mode 100644 index 00000000..33031847 --- /dev/null +++ b/src/useReactions.tsx @@ -0,0 +1,249 @@ +/* +Copyright 2024 Milton Moura + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +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, + useMemo, +} from "react"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; + +interface ReactionsContextType { + raisedHands: Record; + supportsReactions: boolean; + myReactionId: string | null; +} + +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; +} + +export const useReactions = (): ReactionsContextType => { + const context = useContext(ReactionsContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +/** + * Provider that handles raised hand reactions for a given `rtcSession`. + */ +export const ReactionsProvider = ({ + children, + rtcSession, +}: { + children: ReactNode; + rtcSession: MatrixRTCSession; +}): 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(); + + // Calculate our own reaction event. + const myReactionId = useMemo( + (): string | null => + (myUserId && raisedHands[myUserId]?.reactionEventId) ?? null, + [raisedHands, myUserId], + ); + + // Reduce the data down for the consumers. + const resultRaisedHands = useMemo( + () => + Object.fromEntries( + Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), + ), + [raisedHands], + ); + + const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { + setRaisedHands((prevRaisedHands) => ({ + ...prevRaisedHands, + [userId]: info, + })); + }, []); + + const removeRaisedHand = useCallback((userId: string) => { + setRaisedHands( + ({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands, + ); + }, []); + + // 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 userId of Object.keys(raisedHands).filter( + (rhId) => !memberships.find((u) => u.sender == rhId), + )) { + removeRaisedHand(userId); + } + + // 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; + } + if ( + raisedHands[m.sender] && + raisedHands[m.sender].membershipEventId !== m.eventId + ) { + // Membership event for sender has changed since the hand + // was raised, reset. + removeRaisedHand(m.sender); + } + const reaction = getLastReactionEvent(m.eventId, m.sender); + if (reaction) { + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + addRaisedHand(m.sender, { + 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, myUserId, addRaisedHand, removeRaisedHand]); + + // This effect handles any *live* reaction/redactions in the room. + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + if (event.isSending()) { + // Skip any events that are still sending. + return; + } + + const sender = event.getSender(); + const reactionEventId = event.getId(); + if (!sender || !reactionEventId) { + // Skip any event without a sender or event ID. + return; + } + + 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) + if ( + !memberships.some( + (e) => e.eventId === membershipEventId && e.sender === sender, + ) + ) { + logger.warn( + `Reaction target was not a membership event for ${sender}, ignoring`, + ); + return; + } + + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(sender, { + reactionEventId, + membershipEventId, + time: new Date(event.localTimestamp), + }); + } + } else if (event.getType() === EventType.RoomRedaction) { + const targetEvent = event.event.redacts; + const targetUser = Object.entries(raisedHands).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); + + // 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.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); + }; + }, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]); + + return ( + + {children} + + ); +}; diff --git a/src/widget.ts b/src/widget.ts index f08968b6..9d3da479 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 = [ diff --git a/yarn.lock b/yarn.lock index b91892ce..bf4b7536 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1601,6 +1601,38 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== +"@formatjs/ecma402-abstract@2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.1.tgz#2e62bc5c22b0e6a5e13bfec6aac15d3d403e1065" + integrity sha512-O4ywpkdJybrjFc9zyL8qK5aklleIAi5O4nYhBVJaOFtCkNrnU+lKFeJOFC48zpsZQmR8Aok2V79hGpHnzbmFpg== + dependencies: + "@formatjs/fast-memoize" "2.2.2" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/fast-memoize@2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.2.tgz#2409ec10f5f7d6c65f4c04e6c2d6cc56fa1e4cef" + integrity sha512-mzxZcS0g1pOzwZTslJOBTmLzDXseMLLvnh25ymRilCm8QLMObsQ7x/rj9GNrH0iUhZMlFisVOD6J1n6WQqpKPQ== + dependencies: + tslib "2" + +"@formatjs/intl-durationformat@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-durationformat/-/intl-durationformat-0.6.1.tgz#ea376202b1dc70683a3f3e125bb07f4fab1135a5" + integrity sha512-tPSX/D/wjO5ZKnRtwLlUYtjLUBILLX1w6+arU97NpPCpZ8SRWQePu+kDAxDwFKJ/w09idqvSFkJjYGTs6hMd1A== + dependencies: + "@formatjs/ecma402-abstract" "2.2.1" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/intl-localematcher@0.5.6": + version "0.5.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.6.tgz#cd0cd99483673d3196a15b4e2c924cfda7f002f8" + integrity sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA== + dependencies: + tslib "2" + "@gulpjs/to-absolute-glob@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz#1fc2460d3953e1d9b9f2dfdb4bcc99da4710c021" @@ -7631,16 +7663,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2, tslib@^2.0.0, tslib@^2.1.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" + integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== + tslib@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== -tslib@^2.0.0, tslib@^2.1.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" - integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== - tslib@^2.0.3: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"