mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-01 21:25:18 +00:00
Add support for reactions / raised-hands via keyboard shortcuts.
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
"open_search": "Open search",
|
"open_search": "Open search",
|
||||||
"pick_reaction": "Pick reaction",
|
"pick_reaction": "Pick reaction",
|
||||||
"raise_hand": "Raise hand",
|
"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",
|
"register": "Register",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { fireEvent, render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { act } from "react";
|
import { act } from "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";
|
||||||
@@ -30,18 +29,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>
|
||||||
);
|
);
|
||||||
@@ -52,7 +46,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("action.raise_hand_or_send_reaction"));
|
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||||
expect(container).toMatchSnapshot();
|
expect(container).toMatchSnapshot();
|
||||||
@@ -63,7 +57,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("action.raise_hand_or_send_reaction"));
|
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByLabelText("action.raise_hand"));
|
await user.click(getByLabelText("action.raise_hand"));
|
||||||
@@ -88,7 +82,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("action.raise_hand_or_send_reaction"));
|
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 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("action.raise_hand_or_send_reaction"));
|
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByText("🐶"));
|
await user.click(getByText("🐶"));
|
||||||
@@ -127,7 +121,7 @@ test("Can search for and send 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 { getByText, container, getByLabelText } = render(
|
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.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByLabelText("action.open_search"));
|
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 room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, getByPlaceholderText, container } = render(
|
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.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByLabelText("action.open_search"));
|
await user.click(getByLabelText("action.open_search"));
|
||||||
@@ -189,7 +183,7 @@ test("Can close search", 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("action.raise_hand_or_send_reaction"));
|
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByLabelText("action.open_search"));
|
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 room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
const { getByLabelText, container, getByPlaceholderText } = render(
|
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.raise_hand_or_send_reaction"));
|
||||||
await user.click(getByLabelText("action.open_search"));
|
await user.click(getByLabelText("action.open_search"));
|
||||||
|
|||||||
@@ -31,19 +31,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"> {
|
||||||
@@ -55,12 +47,17 @@ const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
|
<Tooltip
|
||||||
|
label={t("action.raise_hand_or_send_reaction", { keyboardShortcut: "H" })}
|
||||||
|
>
|
||||||
<CpdButton
|
<CpdButton
|
||||||
className={classNames(raised && styles.raisedButton)}
|
className={classNames(raised && styles.raisedButton)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup
|
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"}
|
kind={raised || open ? "primary" : "secondary"}
|
||||||
iconOnly
|
iconOnly
|
||||||
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
|
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
|
||||||
@@ -99,7 +96,7 @@ export function ReactionPopupMenu({
|
|||||||
(!!searchText &&
|
(!!searchText &&
|
||||||
(reaction.name.startsWith(searchText) ||
|
(reaction.name.startsWith(searchText) ||
|
||||||
reaction.alias?.some((a) => a.startsWith(searchText)))),
|
reaction.alias?.some((a) => a.startsWith(searchText)))),
|
||||||
).slice(0, 6),
|
).slice(0, ReactionsRowSize),
|
||||||
[searchText, isSearching],
|
[searchText, isSearching],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,9 +172,21 @@ export function ReactionPopupMenu({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<menu className={styles.reactionsMenu}>
|
<menu className={styles.reactionsMenu}>
|
||||||
{filteredReactionSet.map((reaction) => (
|
{filteredReactionSet.map((reaction, index) => (
|
||||||
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
|
<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
|
<CpdButton
|
||||||
kind="secondary"
|
kind="secondary"
|
||||||
className={styles.reactionButton}
|
className={styles.reactionButton}
|
||||||
@@ -212,52 +221,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) {
|
||||||
@@ -267,59 +257,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 (
|
||||||
<>
|
<>
|
||||||
@@ -342,7 +298,7 @@ export function ReactionToggleButton({
|
|||||||
isHandRaised={isHandRaised}
|
isHandRaised={isHandRaised}
|
||||||
canReact={canReact}
|
canReact={canReact}
|
||||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
sendReaction={(reaction) => void sendRelation(reaction)}
|
||||||
toggleRaisedHand={toggleRaisedHand}
|
toggleRaisedHand={wrappedToggleRaisedHand}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ exports[`Can close search 1`] = `
|
|||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":rec:"
|
aria-labelledby=":rec:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":rhh:"
|
aria-labelledby=":rhh:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":r3i:"
|
aria-labelledby=":r3i:"
|
||||||
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":r0:"
|
aria-labelledby=":r0:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":r1p:"
|
aria-labelledby=":r1p:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":r74:"
|
aria-labelledby=":r74:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
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-disabled="false"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
|
aria-keyshortcuts="H"
|
||||||
aria-label="action.raise_hand_or_send_reaction"
|
aria-label="action.raise_hand_or_send_reaction"
|
||||||
aria-labelledby=":ra3:"
|
aria-labelledby=":ra3:"
|
||||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||||
|
|||||||
@@ -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[] = [
|
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}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 } 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,22 @@ const mayReceiveKeyEvents = (e: HTMLElement): boolean => {
|
|||||||
return focusedElement !== null && focusedElement.contains(e);
|
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(
|
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 +61,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 +74,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