From a081ac88472afd7539cff70ed50934380032da00 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:08:19 +0000 Subject: [PATCH 01/14] 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 ca91d5175..7b11e36a6 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 b13b74fab..cab8a5457 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 984d2f4c7..305389b4e 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 bee0bdb14..3902415db 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 0e270a6dd..b2316bc4c 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 18afa95e3..f4340f472 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 d3e4f65ed..6a0a883bb 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 8824f103d..7195cfd06 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 { - 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); + 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; + } } - }, [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} From a7221b3ed772d237d650739a8fcd89705a440ad2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:21:27 +0000 Subject: [PATCH 02/14] Add tests --- src/useCallViewKeyboardShortcuts.test.tsx | 33 +++++++++++++++++++++-- src/useCallViewKeyboardShortcuts.ts | 13 +++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/useCallViewKeyboardShortcuts.test.tsx b/src/useCallViewKeyboardShortcuts.test.tsx index 306bb5f74..9b8d45e77 100644 --- a/src/useCallViewKeyboardShortcuts.test.tsx +++ b/src/useCallViewKeyboardShortcuts.test.tsx @@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web"; import userEvent from "@testing-library/user-event"; import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts"; +import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions"; // Test Explanation: // - The main objective is to test `useCallViewKeyboardShortcuts`. // The TestComponent just wraps a button around that hook. interface TestComponentProps { - setMicrophoneMuted: (muted: boolean) => void; + setMicrophoneMuted?: (muted: boolean) => void; onButtonClick?: () => void; + sendReaction?: () => void; + toggleHandRaised?: () => void; } const TestComponent: FC = ({ - setMicrophoneMuted, + setMicrophoneMuted = (): void => {}, onButtonClick = (): void => {}, + sendReaction = (reaction: ReactionOption): void => {}, + toggleHandRaised = (): void => {}, }) => { const ref = useRef(null); useCallViewKeyboardShortcuts( @@ -32,6 +37,8 @@ const TestComponent: FC = ({ () => {}, () => {}, setMicrophoneMuted, + sendReaction, + toggleHandRaised, ); return (
    @@ -74,6 +81,28 @@ test("spacebar prioritizes pressing a button", async () => { expect(onClick).toBeCalled(); }); +test("reactions can be sent via keyboard presses", async () => { + const user = userEvent.setup(); + + const sendReaction = vi.fn(); + render(); + + for (let index = 1; index <= ReactionsRowSize; index++) { + await user.keyboard(index.toString()); + expect(sendReaction).toHaveBeenNthCalledWith(index, ReactionSet[index - 1]); + } +}); + +test("raised hand can be sent via keyboard presses", async () => { + const user = userEvent.setup(); + + const toggleHandRaised = vi.fn(); + render(); + await user.keyboard("h"); + + expect(toggleHandRaised).toHaveBeenCalledOnce(); +}); + test("unmuting happens in place of the default action", async () => { const user = userEvent.setup(); const defaultPrevented = vi.fn(); diff --git a/src/useCallViewKeyboardShortcuts.ts b/src/useCallViewKeyboardShortcuts.ts index 6a0a883bb..7c27e1e2f 100644 --- a/src/useCallViewKeyboardShortcuts.ts +++ b/src/useCallViewKeyboardShortcuts.ts @@ -8,7 +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"; +import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions"; /** * Determines whether focus is in the same part of the tree as the given @@ -19,14 +19,9 @@ 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], -}; +const KeyToReactionMap: Record = Object.fromEntries( + ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]), +); export function useCallViewKeyboardShortcuts( focusElement: RefObject, From f3b1477ef0c4929ad2452569e511ccc4779204f5 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:26:42 +0000 Subject: [PATCH 03/14] Fixup shortcuts --- public/locales/en-GB/app.json | 6 +++--- src/button/ReactionToggleButton.tsx | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 7b11e36a6..d18bc3a52 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -9,12 +9,12 @@ "edit": "Edit", "go": "Go", "invite": "Invite", - "lower_hand": "Lower hand", + "lower_hand": "Lower hand ({{keyboardShortcut}})", "no": "No", "open_search": "Open search", "pick_reaction": "Pick reaction", - "raise_hand": "Raise hand", - "raise_hand_or_send_reaction": "Raise hand or send reaction ({{keyboardShortcut}})", + "raise_hand": "Raise hand ({{keyboardShortcut}})", + "raise_hand_or_send_reaction": "Raise hand or send reaction", "register": "Register", "remove": "Remove", "sign_in": "Sign in", diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 305389b4e..2c837ca24 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -47,17 +47,12 @@ const InnerButton: FC = ({ raised, open, ...props }) => { const { t } = useTranslation(); return ( - + {errorText && ( @@ -136,6 +133,7 @@ export function ReactionPopupMenu({ toggleRaisedHand()} From 7508d35a034b4fa5d8c3f2e06950ae78007c86cb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:30:18 +0000 Subject: [PATCH 04/14] update snapshotr --- .../__snapshots__/ReactionToggleButton.test.tsx.snap | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap index 3902415db..bee0bdb14 100644 --- a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap +++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap @@ -9,7 +9,6 @@ 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" @@ -43,7 +42,6 @@ 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" @@ -77,7 +75,6 @@ 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" @@ -111,7 +108,6 @@ 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" @@ -145,7 +141,6 @@ 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" @@ -182,7 +177,6 @@ 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" @@ -219,7 +213,6 @@ 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" From f131b803eb33a9365254bb601a8b96e07411aa20 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 14 Nov 2024 17:31:20 +0000 Subject: [PATCH 05/14] fix type --- src/tile/GridTile.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 6a7de7330..27695b656 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -97,7 +97,7 @@ const UserMediaTile = forwardRef( }, [vm], ); - const { raisedHands, lowerHand, reactions } = useReactions(); + const { raisedHands, toggleRaisedHand, reactions } = useReactions(); const AudioIcon = locallyMuted ? VolumeOffSolidIcon @@ -127,8 +127,9 @@ const UserMediaTile = forwardRef( const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; const currentReaction: ReactionOption | undefined = reactions[vm.member?.userId ?? ""]; - const raisedHandOnClick = - vm.local && handRaised ? (): void => void lowerHand() : undefined; + const raisedHandOnClick = vm.local + ? (): void => void toggleRaisedHand() + : undefined; const showSpeaking = showSpeakingIndicators && speaking; From 880bb94deae5fd48977f41dee63f9b7d300f3a9d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 15 Nov 2024 09:43:20 +0000 Subject: [PATCH 06/14] keyshortcuts --- src/button/ReactionToggleButton.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 2c837ca24..7dc6f01ae 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -179,17 +179,17 @@ export function ReactionPopupMenu({ ? reaction.name : `${reaction.name} (${index + 1})` } - aria-keyshortcuts={ - index < ReactionsRowSize - ? (index + 1).toString() - : undefined - } > sendReaction(reaction)} + aria-keyshortcuts={ + index < ReactionsRowSize + ? (index + 1).toString() + : undefined + } > {reaction.emoji} From bcf462aad40f534fdd17465ef10f7ad47f73bf1d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 15 Nov 2024 16:28:17 +0000 Subject: [PATCH 07/14] remove mistakenly commited file --- src/useIsTouchscreen.ts | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 src/useIsTouchscreen.ts diff --git a/src/useIsTouchscreen.ts b/src/useIsTouchscreen.ts deleted file mode 100644 index 7ff0bdf5b..000000000 --- a/src/useIsTouchscreen.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* -Copyright 2022-2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. -*/ - -import { useMediaQuery } from "./useMediaQuery"; - -/** - * @returns Whether the device is a touchscreen device. - */ -// Empirically, Chrome on Android can end up not matching (hover: none), but -// still matching (pointer: coarse) :/ -export const useIsTouchscreen = (): boolean => - useMediaQuery("(hover: none) or (pointer: coarse)"); From a3f6e17b2b3aab2a03d5de87e63387963d863a25 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 15 Nov 2024 16:34:28 +0000 Subject: [PATCH 08/14] fix label logic --- src/button/ReactionToggleButton.tsx | 2 +- src/reactions/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index 9aef9bba3..00384ac61 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -114,7 +114,7 @@ export function ReactionPopupMenu({
  • = ReactionsRowSize ? reaction.name : `${reaction.name} (${index + 1})` } diff --git a/src/reactions/index.ts b/src/reactions/index.ts index b2316bc4c..610e24f0e 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -73,7 +73,7 @@ export const GenericReaction: ReactionOption = { }, }; -export const ReactionsRowSize = 6; +export const ReactionsRowSize = 5; // The first {ReactionsRowSize} reactions are always visible. export const ReactionSet: ReactionOption[] = [ From fee454f286c6f9dc9d575cab9a26bbc4d85fa309 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 15 Nov 2024 18:02:30 +0000 Subject: [PATCH 09/14] Add renderer for call joined / left --- src/room/CallEventAudioRenderer.tsx | 71 +++++++++++++++++++++++++++++ src/room/InCallView.tsx | 2 + src/state/CallViewModel.ts | 4 ++ 3 files changed, 77 insertions(+) create mode 100644 src/room/CallEventAudioRenderer.tsx diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx new file mode 100644 index 000000000..48267e260 --- /dev/null +++ b/src/room/CallEventAudioRenderer.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ReactNode, useDeferredValue, useEffect, useRef } from "react"; + +import { + playReactionsSound, + soundEffectVolumeSetting as effectSoundVolumeSetting, + useSetting, +} from "../settings/settings"; +import { CallViewModel } from "../state/CallViewModel"; +import { useObservableEagerState } from "observable-hooks"; + +// TODO: These need replacing with something more pleasant. +import enterCallSoundMp3 from "../sound/start_talk_local.mp3"; +import enterCallSoundOgg from "../sound/start_talk_local.ogg"; +import leftCallSoundMp3 from "../sound/start_talk_remote.mp3"; +import leftCallSoundOgg from "../sound/start_talk_remote.ogg"; + +export function CallEventAudioRenderer({ + vm, +}: { + vm: CallViewModel; +}): ReactNode { + const [shouldPlay] = useSetting(playReactionsSound); + const [effectSoundVolume] = useSetting(effectSoundVolumeSetting); + const memberIds = useObservableEagerState(vm.userMediaIds); + const previousMembers = useDeferredValue(memberIds); + const callEntered = useRef(null); + const callLeft = useRef(null); + + useEffect(() => { + const memberLeft = !!previousMembers.filter((m) => !memberIds.includes(m)) + .length; + const memberJoined = !!memberIds.filter((m) => !previousMembers.includes(m)) + .length; + + if (callEntered.current && callEntered.current?.paused && memberJoined) { + callEntered.current.volume = effectSoundVolume; + void callEntered.current.play(); + } + + if (callLeft.current && callLeft.current?.paused && memberLeft) { + callLeft.current.volume = effectSoundVolume; + void callLeft.current.play(); + } + }, [callEntered, callLeft, memberIds, previousMembers]); + + // Do not render any audio elements if playback is disabled. Will save + // audio file fetches. + if (!shouldPlay) { + return null; + } + + return ( + <> + + + + ); +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index f4340f472..9f41eff53 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -87,6 +87,7 @@ import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { useSwitchCamera } from "./useSwitchCamera"; import { soundEffectVolumeSetting, useSetting } from "../settings/settings"; import { ReactionsOverlay } from "./ReactionsOverlay"; +import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -669,6 +670,7 @@ export const InCallView: FC = ({ ))} + {renderContent()}