Add support for reactions / raised-hands via keyboard shortcuts.

This commit is contained in:
Half-Shot 2024-11-14 17:08:19 +00:00
parent 6e5c468780
commit a081ac8847
8 changed files with 157 additions and 125 deletions

View File

@ -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",

View File

@ -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<string, string> = {
function TestComponent({
rtcSession,
room,
}: {
rtcSession: MockRTCSession;
room: MockRoom;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
/>
<ReactionToggleButton userId={memberUserIdAlice} />
</TestReactionsWrapper>
</TooltipProvider>
);
@ -52,7 +46,7 @@ test("Can open menu", async () => {
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
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(
<TestComponent rtcSession={rtcSession} room={room} />,
<TestComponent rtcSession={rtcSession} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));

View File

@ -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<InnerButtonProps> = ({ raised, open, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
<Tooltip
label={t("action.raise_hand_or_send_reaction", { keyboardShortcut: "H" })}
>
<CpdButton
className={classNames(raised && styles.raisedButton)}
aria-expanded={open}
aria-haspopup
aria-label={t("action.raise_hand_or_send_reaction")}
aria-keyshortcuts="H"
aria-label={t("action.raise_hand_or_send_reaction", {
keyboardShortcut: "H",
})}
kind={raised || open ? "primary" : "secondary"}
iconOnly
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
@ -99,7 +96,7 @@ export function ReactionPopupMenu({
(!!searchText &&
(reaction.name.startsWith(searchText) ||
reaction.alias?.some((a) => a.startsWith(searchText)))),
).slice(0, 6),
).slice(0, ReactionsRowSize),
[searchText, isSearching],
);
@ -175,9 +172,21 @@ export function ReactionPopupMenu({
</>
) : null}
<menu className={styles.reactionsMenu}>
{filteredReactionSet.map((reaction) => (
{filteredReactionSet.map((reaction, index) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
{/* Show the keyboard key assigned to the reaction */}
<Tooltip
label={
index < ReactionsRowSize
? reaction.name
: `${reaction.name} (${index + 1})`
}
aria-keyshortcuts={
index < ReactionsRowSize
? (index + 1).toString()
: undefined
}
>
<CpdButton
kind="secondary"
className={styles.reactionButton}
@ -212,52 +221,33 @@ export function ReactionPopupMenu({
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
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<string>();
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<void> => {
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<void> => {
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}
/>
</Modal>
</>

View File

@ -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"

View File

@ -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: "👍",

View File

@ -183,7 +183,8 @@ export const InCallView: FC<InCallViewProps> = ({
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<InCallViewProps> = ({
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<InCallViewProps> = ({
<ReactionToggleButton
key="raise_hand"
className={styles.raiseHand}
client={client}
rtcSession={rtcSession}
userId={client.getUserId()!}
onTouchEnd={onControlsTouchEnd}
/>,
);

View File

@ -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<string, ReactionOption> = {
["1"]: ReactionSet[0],
["2"]: ReactionSet[1],
["3"]: ReactionSet[2],
["4"]: ReactionSet[3],
["5"]: ReactionSet[4],
["6"]: ReactionSet[5],
};
export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>,
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

View File

@ -40,7 +40,8 @@ interface ReactionsContextType {
raisedHands: Record<string, Date>;
supportsReactions: boolean;
reactions: Record<string, ReactionOption>;
lowerHand: () => Promise<void>;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
@ -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<number>();
@ -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 (
<ReactionsContext.Provider
@ -345,7 +395,8 @@ export const ReactionsProvider = ({
raisedHands: resultRaisedHands,
supportsReactions,
reactions,
lowerHand,
toggleRaisedHand,
sendReaction,
}}
>
{children}