Add keyboard shortcuts for raised hand / reactions (#2784)
* Add support for reactions / raised-hands via keyboard shortcuts. * Add tests * Fixup shortcuts * update snapshotr * fix type * keyshortcuts * remove mistakenly commited file * fix label logic * Add renderer for call joined / left * Use caption * lint * remove unexpected file * remove other unexpected change * Remove other other unexpected change.
This commit is contained in:
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
@@ -29,18 +28,13 @@ const membership: Record<string, string> = {
|
|||||||
|
|
||||||
function TestComponent({
|
function TestComponent({
|
||||||
rtcSession,
|
rtcSession,
|
||||||
room,
|
|
||||||
}: {
|
}: {
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
room: MockRoom;
|
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||||
<ReactionToggleButton
|
<ReactionToggleButton userId={memberUserIdAlice} />
|
||||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
|
||||||
client={room.client}
|
|
||||||
/>
|
|
||||||
</TestReactionsWrapper>
|
</TestReactionsWrapper>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
@@ -51,7 +45,7 @@ test("Can open menu", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
const { getByLabelText, container } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -62,7 +56,7 @@ test("Can raise hand", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
const { getByLabelText, container } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
await user.click(getByLabelText("action.raise_hand"));
|
await user.click(getByLabelText("action.raise_hand"));
|
||||||
@@ -87,7 +81,7 @@ test("Can lower hand", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
const { getByLabelText, container } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
|
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
@@ -101,7 +95,7 @@ test("Can react with emoji", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, getByText } = render(
|
const { getByLabelText, getByText } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
await user.click(getByText("🐶"));
|
await user.click(getByText("🐶"));
|
||||||
@@ -126,7 +120,7 @@ test("Can fully expand emoji picker", async () => {
|
|||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByText, container, getByLabelText } = render(
|
const { getByText, container, getByLabelText } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
await user.click(getByLabelText("action.show_more"));
|
await user.click(getByLabelText("action.show_more"));
|
||||||
@@ -149,12 +143,12 @@ test("Can fully expand emoji picker", async () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Can close search", async () => {
|
test("Can close reaction dialog", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container } = render(
|
const { getByLabelText, container } = render(
|
||||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
<TestComponent rtcSession={rtcSession} />,
|
||||||
);
|
);
|
||||||
await user.click(getByLabelText("common.reactions"));
|
await user.click(getByLabelText("common.reactions"));
|
||||||
await user.click(getByLabelText("action.show_more"));
|
await user.click(getByLabelText("action.show_more"));
|
||||||
|
|||||||
@@ -23,19 +23,11 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
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 classNames from "classnames";
|
||||||
|
|
||||||
import { useReactions } from "../useReactions";
|
import { useReactions } from "../useReactions";
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
|
||||||
import styles from "./ReactionToggleButton.module.css";
|
import styles from "./ReactionToggleButton.module.css";
|
||||||
import {
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "../reactions";
|
||||||
ReactionOption,
|
|
||||||
ReactionSet,
|
|
||||||
ElementCallReactionEventType,
|
|
||||||
} from "../reactions";
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
|
|
||||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
@@ -95,9 +87,10 @@ export function ReactionPopupMenu({
|
|||||||
)}
|
)}
|
||||||
<div className={styles.reactionPopupMenu}>
|
<div className={styles.reactionPopupMenu}>
|
||||||
<section className={styles.handRaiseSection}>
|
<section className={styles.handRaiseSection}>
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label} caption="H">
|
||||||
<CpdButton
|
<CpdButton
|
||||||
kind={isHandRaised ? "primary" : "secondary"}
|
kind={isHandRaised ? "primary" : "secondary"}
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-pressed={isHandRaised}
|
aria-pressed={isHandRaised}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => toggleRaisedHand()}
|
onClick={() => toggleRaisedHand()}
|
||||||
@@ -114,14 +107,26 @@ export function ReactionPopupMenu({
|
|||||||
styles.reactionsMenu,
|
styles.reactionsMenu,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{filteredReactionSet.map((reaction) => (
|
{filteredReactionSet.map((reaction, index) => (
|
||||||
<li key={reaction.name}>
|
<li key={reaction.name}>
|
||||||
<Tooltip label={reaction.name}>
|
<Tooltip
|
||||||
|
label={reaction.name}
|
||||||
|
caption={
|
||||||
|
index < ReactionsRowSize
|
||||||
|
? (index + 1).toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<CpdButton
|
<CpdButton
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
className={styles.reactionButton}
|
className={styles.reactionButton}
|
||||||
disabled={!canReact}
|
disabled={!canReact}
|
||||||
onClick={() => sendReaction(reaction)}
|
onClick={() => sendReaction(reaction)}
|
||||||
|
aria-keyshortcuts={
|
||||||
|
index < ReactionsRowSize
|
||||||
|
? (index + 1).toString()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{reaction.emoji}
|
{reaction.emoji}
|
||||||
</CpdButton>
|
</CpdButton>
|
||||||
@@ -153,52 +158,33 @@ export function ReactionPopupMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||||
rtcSession: MatrixRTCSession;
|
userId: string;
|
||||||
client: MatrixClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReactionToggleButton({
|
export function ReactionToggleButton({
|
||||||
client,
|
userId,
|
||||||
rtcSession,
|
|
||||||
...props
|
...props
|
||||||
}: ReactionToggleButtonProps): ReactNode {
|
}: ReactionToggleButtonProps): ReactNode {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
const { raisedHands, toggleRaisedHand, sendReaction, reactions } =
|
||||||
|
useReactions();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const userId = client.getUserId()!;
|
|
||||||
const isHandRaised = !!raisedHands[userId];
|
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
|
||||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||||
const [errorText, setErrorText] = useState<string>();
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
|
||||||
|
const isHandRaised = !!raisedHands[userId];
|
||||||
|
const canReact = !reactions[userId];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear whenever the reactions menu state changes.
|
// Clear whenever the reactions menu state changes.
|
||||||
setErrorText(undefined);
|
setErrorText(undefined);
|
||||||
}, [showReactionsMenu]);
|
}, [showReactionsMenu]);
|
||||||
|
|
||||||
const canReact = !reactions[userId];
|
|
||||||
|
|
||||||
const sendRelation = useCallback(
|
const sendRelation = useCallback(
|
||||||
async (reaction: ReactionOption) => {
|
async (reaction: ReactionOption) => {
|
||||||
try {
|
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);
|
setBusy(true);
|
||||||
await client.sendEvent(
|
await sendReaction(reaction);
|
||||||
rtcSession.room.roomId,
|
|
||||||
ElementCallReactionEventType,
|
|
||||||
{
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: RelationType.Reference,
|
|
||||||
event_id: parentEventId,
|
|
||||||
},
|
|
||||||
emoji: reaction.emoji,
|
|
||||||
name: reaction.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setErrorText(undefined);
|
setErrorText(undefined);
|
||||||
setShowReactionsMenu(false);
|
setShowReactionsMenu(false);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -208,59 +194,25 @@ export function ReactionToggleButton({
|
|||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[memberships, client, userId, rtcSession],
|
[sendReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleRaisedHand = useCallback(() => {
|
const wrappedToggleRaisedHand = useCallback(() => {
|
||||||
const raiseHand = async (): Promise<void> => {
|
const toggleHand = async (): Promise<void> => {
|
||||||
if (isHandRaised) {
|
try {
|
||||||
try {
|
setBusy(true);
|
||||||
setBusy(true);
|
await toggleRaisedHand();
|
||||||
await lowerHand();
|
setShowReactionsMenu(false);
|
||||||
setShowReactionsMenu(false);
|
} catch (ex) {
|
||||||
} finally {
|
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
|
||||||
setBusy(false);
|
logger.error("Failed to raise/lower hand", ex);
|
||||||
}
|
} finally {
|
||||||
} else {
|
setBusy(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void raiseHand();
|
void toggleHand();
|
||||||
}, [
|
}, [toggleRaisedHand]);
|
||||||
client,
|
|
||||||
isHandRaised,
|
|
||||||
memberships,
|
|
||||||
lowerHand,
|
|
||||||
rtcSession.room.roomId,
|
|
||||||
userId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -284,7 +236,7 @@ export function ReactionToggleButton({
|
|||||||
isHandRaised={isHandRaised}
|
isHandRaised={isHandRaised}
|
||||||
canReact={!busy && canReact}
|
canReact={!busy && canReact}
|
||||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
sendReaction={(reaction) => void sendRelation(reaction)}
|
||||||
toggleRaisedHand={toggleRaisedHand}
|
toggleRaisedHand={wrappedToggleRaisedHand}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`Can close search 1`] = `
|
exports[`Can close reaction dialog 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-aria-hidden="true"
|
data-aria-hidden="true"
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export const GenericReaction: ReactionOption = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// The first 6 reactions are always visible.
|
export const ReactionsRowSize = 5;
|
||||||
|
|
||||||
|
// The first {ReactionsRowSize} reactions are always visible.
|
||||||
export const ReactionSet: ReactionOption[] = [
|
export const ReactionSet: ReactionOption[] = [
|
||||||
{
|
{
|
||||||
emoji: "👍",
|
emoji: "👍",
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
onShareClick,
|
onShareClick,
|
||||||
}) => {
|
}) => {
|
||||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||||
const { supportsReactions, raisedHands } = useReactions();
|
const { supportsReactions, raisedHands, sendReaction, toggleRaisedHand } =
|
||||||
|
useReactions();
|
||||||
const raisedHandCount = useMemo(
|
const raisedHandCount = useMemo(
|
||||||
() => Object.keys(raisedHands).length,
|
() => Object.keys(raisedHands).length,
|
||||||
[raisedHands],
|
[raisedHands],
|
||||||
@@ -227,6 +228,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
toggleMicrophone,
|
toggleMicrophone,
|
||||||
toggleCamera,
|
toggleCamera,
|
||||||
(muted) => muteStates.audio.setEnabled?.(!muted),
|
(muted) => muteStates.audio.setEnabled?.(!muted),
|
||||||
|
(reaction) => void sendReaction(reaction),
|
||||||
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const windowMode = useObservableEagerState(vm.windowMode);
|
const windowMode = useObservableEagerState(vm.windowMode);
|
||||||
@@ -572,8 +575,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
<ReactionToggleButton
|
<ReactionToggleButton
|
||||||
key="raise_hand"
|
key="raise_hand"
|
||||||
className={styles.raiseHand}
|
className={styles.raiseHand}
|
||||||
client={client}
|
userId={client.getUserId()!}
|
||||||
rtcSession={rtcSession}
|
|
||||||
onTouchEnd={onControlsTouchEnd}
|
onTouchEnd={onControlsTouchEnd}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
},
|
},
|
||||||
[vm],
|
[vm],
|
||||||
);
|
);
|
||||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
const { raisedHands, toggleRaisedHand, reactions } = useReactions();
|
||||||
|
|
||||||
const AudioIcon = locallyMuted
|
const AudioIcon = locallyMuted
|
||||||
? VolumeOffSolidIcon
|
? VolumeOffSolidIcon
|
||||||
@@ -127,8 +127,9 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
|
||||||
const currentReaction: ReactionOption | undefined =
|
const currentReaction: ReactionOption | undefined =
|
||||||
reactions[vm.member?.userId ?? ""];
|
reactions[vm.member?.userId ?? ""];
|
||||||
const raisedHandOnClick =
|
const raisedHandOnClick = vm.local
|
||||||
vm.local && handRaised ? (): void => void lowerHand() : undefined;
|
? (): void => void toggleRaisedHand()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const showSpeaking = showSpeakingIndicators && speaking;
|
const showSpeaking = showSpeakingIndicators && speaking;
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,24 @@ import { Button } from "@vector-im/compound-web";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcuts";
|
||||||
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||||
|
|
||||||
// Test Explanation:
|
// Test Explanation:
|
||||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||||
// The TestComponent just wraps a button around that hook.
|
// The TestComponent just wraps a button around that hook.
|
||||||
|
|
||||||
interface TestComponentProps {
|
interface TestComponentProps {
|
||||||
setMicrophoneMuted: (muted: boolean) => void;
|
setMicrophoneMuted?: (muted: boolean) => void;
|
||||||
onButtonClick?: () => void;
|
onButtonClick?: () => void;
|
||||||
|
sendReaction?: () => void;
|
||||||
|
toggleHandRaised?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TestComponent: FC<TestComponentProps> = ({
|
const TestComponent: FC<TestComponentProps> = ({
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted = (): void => {},
|
||||||
onButtonClick = (): void => {},
|
onButtonClick = (): void => {},
|
||||||
|
sendReaction = (reaction: ReactionOption): void => {},
|
||||||
|
toggleHandRaised = (): void => {},
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useCallViewKeyboardShortcuts(
|
useCallViewKeyboardShortcuts(
|
||||||
@@ -32,6 +37,8 @@ const TestComponent: FC<TestComponentProps> = ({
|
|||||||
() => {},
|
() => {},
|
||||||
() => {},
|
() => {},
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted,
|
||||||
|
sendReaction,
|
||||||
|
toggleHandRaised,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
@@ -74,6 +81,28 @@ test("spacebar prioritizes pressing a button", async () => {
|
|||||||
expect(onClick).toBeCalled();
|
expect(onClick).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reactions can be sent via keyboard presses", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const sendReaction = vi.fn();
|
||||||
|
render(<TestComponent sendReaction={sendReaction} />);
|
||||||
|
|
||||||
|
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(<TestComponent toggleHandRaised={toggleHandRaised} />);
|
||||||
|
await user.keyboard("h");
|
||||||
|
|
||||||
|
expect(toggleHandRaised).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
test("unmuting happens in place of the default action", async () => {
|
test("unmuting happens in place of the default action", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const defaultPrevented = vi.fn();
|
const defaultPrevented = vi.fn();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { RefObject, useCallback, useMemo, useRef } from "react";
|
import { RefObject, useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { useEventTarget } from "./useEvents";
|
import { useEventTarget } from "./useEvents";
|
||||||
|
import { ReactionOption, ReactionSet, ReactionsRowSize } from "./reactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether focus is in the same part of the tree as the given
|
* Determines whether focus is in the same part of the tree as the given
|
||||||
@@ -18,11 +19,17 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
|||||||
return focusedElement !== null && focusedElement.contains(e);
|
return focusedElement !== null && focusedElement.contains(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
||||||
|
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
|
||||||
|
);
|
||||||
|
|
||||||
export function useCallViewKeyboardShortcuts(
|
export function useCallViewKeyboardShortcuts(
|
||||||
focusElement: RefObject<HTMLElement | null>,
|
focusElement: RefObject<HTMLElement | null>,
|
||||||
toggleMicrophoneMuted: () => void,
|
toggleMicrophoneMuted: () => void,
|
||||||
toggleLocalVideoMuted: () => void,
|
toggleLocalVideoMuted: () => void,
|
||||||
setMicrophoneMuted: (muted: boolean) => void,
|
setMicrophoneMuted: (muted: boolean) => void,
|
||||||
|
sendReaction: (reaction: ReactionOption) => void,
|
||||||
|
toggleHandRaised: () => void,
|
||||||
): void {
|
): void {
|
||||||
const spacebarHeld = useRef(false);
|
const spacebarHeld = useRef(false);
|
||||||
|
|
||||||
@@ -49,6 +56,12 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
spacebarHeld.current = true;
|
spacebarHeld.current = true;
|
||||||
setMicrophoneMuted(false);
|
setMicrophoneMuted(false);
|
||||||
}
|
}
|
||||||
|
} else if (event.key === "h") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleHandRaised();
|
||||||
|
} else if (KeyToReactionMap[event.key]) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendReaction(KeyToReactionMap[event.key]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -56,6 +69,8 @@ export function useCallViewKeyboardShortcuts(
|
|||||||
toggleLocalVideoMuted,
|
toggleLocalVideoMuted,
|
||||||
toggleMicrophoneMuted,
|
toggleMicrophoneMuted,
|
||||||
setMicrophoneMuted,
|
setMicrophoneMuted,
|
||||||
|
sendReaction,
|
||||||
|
toggleHandRaised,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Because this is set on the window, to prevent shortcuts from activating
|
// Because this is set on the window, to prevent shortcuts from activating
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ interface ReactionsContextType {
|
|||||||
raisedHands: Record<string, Date>;
|
raisedHands: Record<string, Date>;
|
||||||
supportsReactions: boolean;
|
supportsReactions: boolean;
|
||||||
reactions: Record<string, ReactionOption>;
|
reactions: Record<string, ReactionOption>;
|
||||||
lowerHand: () => Promise<void>;
|
toggleRaisedHand: () => Promise<void>;
|
||||||
|
sendReaction: (reaction: ReactionOption) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
||||||
@@ -104,7 +105,6 @@ export const ReactionsProvider = ({
|
|||||||
),
|
),
|
||||||
[raisedHands],
|
[raisedHands],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
||||||
setRaisedHands((prevRaisedHands) => ({
|
setRaisedHands((prevRaisedHands) => ({
|
||||||
...prevRaisedHands,
|
...prevRaisedHands,
|
||||||
@@ -181,6 +181,11 @@ export const ReactionsProvider = ({
|
|||||||
const latestMemberships = useLatest(memberships);
|
const latestMemberships = useLatest(memberships);
|
||||||
const latestRaisedHands = useLatest(raisedHands);
|
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.
|
// This effect handles any *live* reaction/redactions in the room.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reactionTimeouts = new Set<number>();
|
const reactionTimeouts = new Set<number>();
|
||||||
@@ -322,22 +327,67 @@ export const ReactionsProvider = ({
|
|||||||
latestRaisedHands,
|
latestRaisedHands,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lowerHand = useCallback(async () => {
|
const toggleRaisedHand = useCallback(async () => {
|
||||||
if (!myUserId || !raisedHands[myUserId]) {
|
if (!myUserId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const myReactionId = raisedHands[myUserId].reactionEventId;
|
const myReactionId = raisedHands[myUserId]?.reactionEventId;
|
||||||
|
|
||||||
if (!myReactionId) {
|
if (!myReactionId) {
|
||||||
logger.warn(`Hand raised but no reaction event to redact!`);
|
try {
|
||||||
return;
|
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 {
|
}, [myMembership, myUserId, raisedHands, rtcSession, room]);
|
||||||
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
|
|
||||||
logger.debug("Redacted raise hand event");
|
const sendReaction = useCallback(
|
||||||
} catch (ex) {
|
async (reaction: ReactionOption) => {
|
||||||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
if (!myUserId || reactions[myUserId]) {
|
||||||
}
|
// We're still reacting
|
||||||
}, [myUserId, raisedHands, rtcSession, room]);
|
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 (
|
return (
|
||||||
<ReactionsContext.Provider
|
<ReactionsContext.Provider
|
||||||
@@ -345,7 +395,8 @@ export const ReactionsProvider = ({
|
|||||||
raisedHands: resultRaisedHands,
|
raisedHands: resultRaisedHands,
|
||||||
supportsReactions,
|
supportsReactions,
|
||||||
reactions,
|
reactions,
|
||||||
lowerHand,
|
toggleRaisedHand,
|
||||||
|
sendReaction,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user