From a081ac88472afd7539cff70ed50934380032da00 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:08:19 +0000 Subject: [PATCH] Add support for reactions / raised-hands via keyboard shortcuts. --- public/locales/en-GB/app.json | 2 +- src/button/ReactionToggleButton.test.tsx | 24 ++-- src/button/ReactionToggleButton.tsx | 136 ++++++------------ .../ReactionToggleButton.test.tsx.snap | 7 + src/reactions/index.ts | 4 +- src/room/InCallView.tsx | 8 +- src/useCallViewKeyboardShortcuts.ts | 20 +++ src/useReactions.tsx | 81 +++++++++-- 8 files changed, 157 insertions(+), 125 deletions(-) diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index ca91d517..7b11e36a 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -14,7 +14,7 @@ "open_search": "Open search", "pick_reaction": "Pick reaction", "raise_hand": "Raise hand", - "raise_hand_or_send_reaction": "Raise hand or send reaction", + "raise_hand_or_send_reaction": "Raise hand or send reaction ({{keyboardShortcut}})", "register": "Register", "remove": "Remove", "sign_in": "Sign in", diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index b13b74fa..cab8a545 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details. import { fireEvent, render } from "@testing-library/react"; import { act } from "react"; import { expect, test } from "vitest"; -import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { TooltipProvider } from "@vector-im/compound-web"; import { userEvent } from "@testing-library/user-event"; import { ReactNode } from "react"; @@ -30,18 +29,13 @@ const membership: Record = { function TestComponent({ rtcSession, - room, }: { rtcSession: MockRTCSession; - room: MockRoom; }): ReactNode { return ( - + ); @@ -52,7 +46,7 @@ test("Can open menu", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); expect(container).toMatchSnapshot(); @@ -63,7 +57,7 @@ test("Can raise hand", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByLabelText("action.raise_hand")); @@ -88,7 +82,7 @@ test("Can lower hand", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container } = render( - , + , ); const reactionEvent = room.testSendHandRaise(memberEventAlice, membership); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); @@ -102,7 +96,7 @@ test("Can react with emoji", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, getByText } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByText("🐶")); @@ -127,7 +121,7 @@ test("Can search for and send emoji", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByText, container, getByLabelText } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByLabelText("action.open_search")); @@ -157,7 +151,7 @@ test("Can search for and send emoji with the keyboard", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, getByPlaceholderText, container } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByLabelText("action.open_search")); @@ -189,7 +183,7 @@ test("Can close search", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByLabelText("action.open_search")); @@ -202,7 +196,7 @@ test("Can close search with the escape key", async () => { const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); const { getByLabelText, container, getByPlaceholderText } = render( - , + , ); await user.click(getByLabelText("action.raise_hand_or_send_reaction")); await user.click(getByLabelText("action.open_search")); diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 984d2f4c..305389b4 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -31,19 +31,11 @@ import { } 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 classNames from "classnames"; import { useReactions } from "../useReactions"; -import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import styles from "./ReactionToggleButton.module.css"; -import { - ReactionOption, - ReactionSet, - ElementCallReactionEventType, -} from "../reactions"; +import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions"; import { Modal } from "../Modal"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { @@ -55,12 +47,17 @@ const InnerButton: FC = ({ raised, open, ...props }) => { const { t } = useTranslation(); return ( - + a.startsWith(searchText)))), - ).slice(0, 6), + ).slice(0, ReactionsRowSize), [searchText, isSearching], ); @@ -175,9 +172,21 @@ export function ReactionPopupMenu({ ) : null} - {filteredReactionSet.map((reaction) => ( + {filteredReactionSet.map((reaction, index) => (
  • - + {/* Show the keyboard key assigned to the reaction */} + { - rtcSession: MatrixRTCSession; - client: MatrixClient; + userId: string; } export function ReactionToggleButton({ - client, - rtcSession, + userId, ...props }: ReactionToggleButtonProps): ReactNode { const { t } = useTranslation(); - const { raisedHands, lowerHand, reactions } = useReactions(); + const { raisedHands, toggleRaisedHand, sendReaction, reactions } = + useReactions(); const [busy, setBusy] = useState(false); - const userId = client.getUserId()!; - const isHandRaised = !!raisedHands[userId]; - const memberships = useMatrixRTCSessionMemberships(rtcSession); const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [errorText, setErrorText] = useState(); + const isHandRaised = !!raisedHands[userId]; + const canReact = !reactions[userId]; + useEffect(() => { // Clear whenever the reactions menu state changes. setErrorText(undefined); }, [showReactionsMenu]); - const canReact = !reactions[userId]; - const sendRelation = useCallback( async (reaction: ReactionOption) => { try { - const myMembership = memberships.find((m) => m.sender === userId); - if (!myMembership?.eventId) { - throw new Error("Cannot find own membership event"); - } - const parentEventId = myMembership.eventId; setBusy(true); - await client.sendEvent( - rtcSession.room.roomId, - ElementCallReactionEventType, - { - "m.relates_to": { - rel_type: RelationType.Reference, - event_id: parentEventId, - }, - emoji: reaction.emoji, - name: reaction.name, - }, - ); + await sendReaction(reaction); setErrorText(undefined); setShowReactionsMenu(false); } catch (ex) { @@ -267,59 +257,25 @@ export function ReactionToggleButton({ setBusy(false); } }, - [memberships, client, userId, rtcSession], + [sendReaction], ); - const toggleRaisedHand = useCallback(() => { - const raiseHand = async (): Promise => { - if (isHandRaised) { - try { - setBusy(true); - await lowerHand(); - setShowReactionsMenu(false); - } finally { - setBusy(false); - } - } else { - try { - const myMembership = memberships.find((m) => m.sender === userId); - if (!myMembership?.eventId) { - throw new Error("Cannot find own membership event"); - } - const parentEventId = myMembership.eventId; - 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); - setErrorText(undefined); - setShowReactionsMenu(false); - } catch (ex) { - setErrorText(ex instanceof Error ? ex.message : "Unknown error"); - logger.error("Failed to raise hand", ex); - } finally { - setBusy(false); - } + const wrappedToggleRaisedHand = useCallback(() => { + const toggleHand = async (): Promise => { + try { + setBusy(true); + await toggleRaisedHand(); + setShowReactionsMenu(false); + } catch (ex) { + setErrorText(ex instanceof Error ? ex.message : "Unknown error"); + logger.error("Failed to raise/lower hand", ex); + } finally { + setBusy(false); } }; - void raiseHand(); - }, [ - client, - isHandRaised, - memberships, - lowerHand, - rtcSession.room.roomId, - userId, - ]); + void toggleHand(); + }, [toggleRaisedHand]); return ( <> @@ -342,7 +298,7 @@ export function ReactionToggleButton({ isHandRaised={isHandRaised} canReact={canReact} sendReaction={(reaction) => void sendRelation(reaction)} - toggleRaisedHand={toggleRaisedHand} + toggleRaisedHand={wrappedToggleRaisedHand} /> diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap index bee0bdb1..3902415d 100644 --- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap +++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap @@ -9,6 +9,7 @@ exports[`Can close search 1`] = ` aria-disabled="false" aria-expanded="true" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":rec:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -42,6 +43,7 @@ exports[`Can close search with the escape key 1`] = ` aria-disabled="false" aria-expanded="false" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":rhh:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -75,6 +77,7 @@ exports[`Can lower hand 1`] = ` aria-disabled="false" aria-expanded="false" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":r3i:" class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -108,6 +111,7 @@ exports[`Can open menu 1`] = ` aria-disabled="false" aria-expanded="true" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":r0:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -141,6 +145,7 @@ exports[`Can raise hand 1`] = ` aria-disabled="false" aria-expanded="false" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":r1p:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -177,6 +182,7 @@ exports[`Can search for and send emoji 1`] = ` aria-disabled="false" aria-expanded="true" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":r74:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" @@ -213,6 +219,7 @@ exports[`Can search for and send emoji with the keyboard 1`] = ` aria-disabled="false" aria-expanded="true" aria-haspopup="true" + aria-keyshortcuts="H" aria-label="action.raise_hand_or_send_reaction" aria-labelledby=":ra3:" class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" diff --git a/src/reactions/index.ts b/src/reactions/index.ts index 0e270a6d..b2316bc4 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = { }, }; -// The first 6 reactions are always visible. +export const ReactionsRowSize = 6; + +// The first {ReactionsRowSize} reactions are always visible. export const ReactionSet: ReactionOption[] = [ { emoji: "👍", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 18afa95e..f4340f47 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -183,7 +183,8 @@ export const InCallView: FC = ({ onShareClick, }) => { const [soundEffectVolume] = useSetting(soundEffectVolumeSetting); - const { supportsReactions, raisedHands } = useReactions(); + const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } = + useReactions(); const raisedHandCount = useMemo( () => Object.keys(raisedHands).length, [raisedHands], @@ -227,6 +228,8 @@ export const InCallView: FC = ({ toggleMicrophone, toggleCamera, (muted) => muteStates.audio.setEnabled?.(!muted), + (reaction) => void sendReaction(reaction), + () => void toggleRaisedHand(), ); const windowMode = useObservableEagerState(vm.windowMode); @@ -572,8 +575,7 @@ export const InCallView: FC = ({ , ); diff --git a/src/useCallViewKeyboardShortcuts.ts b/src/useCallViewKeyboardShortcuts.ts index d3e4f65e..6a0a883b 100644 --- a/src/useCallViewKeyboardShortcuts.ts +++ b/src/useCallViewKeyboardShortcuts.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { RefObject, useCallback, useMemo, useRef } from "react"; import { useEventTarget } from "./useEvents"; +import { ReactionOption, ReactionSet } from "./reactions"; /** * Determines whether focus is in the same part of the tree as the given @@ -18,11 +19,22 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => { return focusedElement !== null && focusedElement.contains(e); }; +const KeyToReactionMap: Record = { + ["1"]: ReactionSet[0], + ["2"]: ReactionSet[1], + ["3"]: ReactionSet[2], + ["4"]: ReactionSet[3], + ["5"]: ReactionSet[4], + ["6"]: ReactionSet[5], +}; + export function useCallViewKeyboardShortcuts( focusElement: RefObject, toggleMicrophoneMuted: () => void, toggleLocalVideoMuted: () => void, setMicrophoneMuted: (muted: boolean) => void, + sendReaction: (reaction: ReactionOption) => void, + toggleHandRaised: () => void, ): void { const spacebarHeld = useRef(false); @@ -49,6 +61,12 @@ export function useCallViewKeyboardShortcuts( spacebarHeld.current = true; setMicrophoneMuted(false); } + } else if (event.key === "h") { + event.preventDefault(); + toggleHandRaised(); + } else if (KeyToReactionMap[event.key]) { + event.preventDefault(); + sendReaction(KeyToReactionMap[event.key]); } }, [ @@ -56,6 +74,8 @@ export function useCallViewKeyboardShortcuts( toggleLocalVideoMuted, toggleMicrophoneMuted, setMicrophoneMuted, + sendReaction, + toggleHandRaised, ], ), // Because this is set on the window, to prevent shortcuts from activating diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 8824f103..7195cfd0 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -40,7 +40,8 @@ interface ReactionsContextType { raisedHands: Record; supportsReactions: boolean; reactions: Record; - lowerHand: () => Promise; + toggleRaisedHand: () => Promise; + sendReaction: (reaction: ReactionOption) => Promise; } const ReactionsContext = createContext( @@ -104,7 +105,6 @@ export const ReactionsProvider = ({ ), [raisedHands], ); - const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { setRaisedHands((prevRaisedHands) => ({ ...prevRaisedHands, @@ -181,6 +181,11 @@ export const ReactionsProvider = ({ const latestMemberships = useLatest(memberships); const latestRaisedHands = useLatest(raisedHands); + const myMembership = useMemo( + () => memberships.find((m) => m.sender === myUserId)?.eventId, + [memberships, myUserId], + ); + // This effect handles any *live* reaction/redactions in the room. useEffect(() => { const reactionTimeouts = new Set(); @@ -322,22 +327,67 @@ export const ReactionsProvider = ({ latestRaisedHands, ]); - const lowerHand = useCallback(async () => { - if (!myUserId || !raisedHands[myUserId]) { + const toggleRaisedHand = useCallback(async () => { + if (!myUserId) { return; } - const myReactionId = raisedHands[myUserId].reactionEventId; + const myReactionId = raisedHands[myUserId]?.reactionEventId; + if (!myReactionId) { - logger.warn(`Hand raised but no reaction event to redact!`); - return; + try { + if (!myMembership) { + 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: myMembership, + 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; + } } - 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); - } - }, [myUserId, raisedHands, rtcSession, room]); + }, [myMembership, myUserId, raisedHands, rtcSession, room]); + + const sendReaction = useCallback( + async (reaction: ReactionOption) => { + if (!myUserId || reactions[myUserId]) { + // We're still reacting + return; + } + if (!myMembership) { + 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: myMembership, + }, + emoji: reaction.emoji, + name: reaction.name, + }, + ); + }, + [myMembership, reactions, room, myUserId, rtcSession], + ); return ( {children}