mirror of
https://github.com/vector-im/element-call.git
synced 2026-02-23 05:07:03 +00:00
Support for generic reactions (#2708)
* Initial support for Hand Raise feature Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Refactored to use reaction and redaction events Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Replacing button svg with raised hand emoji Signed-off-by: Milton Moura <miltonmoura@gmail.com> * SpotlightTile should not duplicate the raised hand Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Update src/room/useRaisedHands.tsx Element Call recently changed to AGPL-3.0 * Use relations to load existing reactions when joining the call Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Links to sha commit of matrix-js-sdk that exposes the call membership event id and refactors some async code Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Removing RaiseHand.svg * Check for reaction & redaction capabilities in widget mode Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Fix failing GridTile test Signed-off-by: Milton Moura <miltonmoura@gmail.com> * Center align hand raise. * Add support for displaying the duration of a raised hand. * Add a sound for when a hand is raised. * Refactor raised hand indicator and add tests. * lint * Refactor into own files. * Redact the right thing. * Tidy up useEffect * Lint tests * Remove extra layer * Add better sound. (woosh) * Add a small mode for spotlight * Fix timestamp calculation on relaod. * Fix call border resizing video * lint * Fix and update tests * Allow timer to be configurable. * Add preferences tab for choosing to enable timer. * Drop border from raised hand icon * Handle cases when a new member event happens. * Prevent infinite loop * Major refactor to support various state problems. * Tidy up and finish test rewrites * Add some explanation comments. * Even more comments. * Use proper duration formatter * Remove rerender * Fix redactions not working because they pick up events in transit. * More tidying * Use deferred value * linting * Add tests for cases where we got a reaction from someone else. * Be even less brittle. * Transpose border to GridTile. * First PoC for reactions * hide menu by default * Add lightbulb. * Add reaction indicator. * Add sounds. * Tidy up + add support for floating emoji. * Linting and general stability improvements. * Subscribe to the ecall reaction event type. * fix import * Center emoji picker * Overflow buttons when screen is too narrow * lint * Add settings for disabling animations / sounds. * Make vertical divider more visually distinct. * Make event listener more resillient. * lint * Fix some tests. * Remove old raised hand component * Add new icon * Update text * Update compound hand raised icon. * Add deer. * Fix case where you could send larger strings as emoji * Const the active time. * Document time in css. * Add rock emoji * Add licence file. * Add type def for custom reaction type. * better reaction description * Factor out reactions test structure to utils file. * Add tests for ReactionToggleButton * Add keyboard shortcuts for reaction sending. * type tidyups * lint * Add tests for ReactionAudioRenderer * lint * prettier * i18n sort * final lint? * Preload reaction sounds to prevent delays. * Update rock sounds * add onclick back * Fix test * lint * simplify * Tweak line height * modal impl * Modal refactor attempts. * Remove closed menu test since we're using Modal. * Swap icon, make mobile view better. * Fix mobile view for emoji picker. * Use Intl.Segmenter * Clear timeouts on component close. * Remove useless useCallback * Use prefers-reduced-motion * Add toggle for raise hand. * Add lower hand text * Add lower motion mode. * Decomplicate className system for Modal * Add error for failured to send reaction. * i18n * Spacing for emoji buttons search * Remove unrequired media query * Fix generic sound not playing. * Clear reactions if we're clearing timeouts. * Fix tests * Relabel lower hand * More translations * Add comments on reaction interface * Move polyfill. * lint * Replace deer sound * Another attempt to fix the sizing of the reactions * cleanup * fix button * fix --------- Signed-off-by: Milton Moura <miltonmoura@gmail.com> Co-authored-by: Milton Moura <miltonmoura@gmail.com> Co-authored-by: fkwp <fkwp@users.noreply.github.com>
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
ComponentPropsWithoutRef,
|
||||
FC,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} 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 { useReactions } from "../useReactions";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
raised: boolean;
|
||||
}
|
||||
|
||||
const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("common.raise_hand")}>
|
||||
<CpdButton
|
||||
kind={raised ? "primary" : "secondary"}
|
||||
{...props}
|
||||
style={{ paddingLeft: 8, paddingRight: 8 }}
|
||||
>
|
||||
<p
|
||||
role="img"
|
||||
aria-hidden
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "0px",
|
||||
display: "inline-block",
|
||||
fontSize: "22px",
|
||||
}}
|
||||
>
|
||||
✋
|
||||
</p>
|
||||
</CpdButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface RaisedHandToggleButtonProps {
|
||||
rtcSession: MatrixRTCSession;
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
export function RaiseHandToggleButton({
|
||||
client,
|
||||
rtcSession,
|
||||
}: RaisedHandToggleButtonProps): ReactNode {
|
||||
const { raisedHands, lowerHand } = useReactions();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const userId = client.getUserId()!;
|
||||
const isHandRaised = !!raisedHands[userId];
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const toggleRaisedHand = useCallback(() => {
|
||||
const raiseHand = async (): Promise<void> => {
|
||||
if (isHandRaised) {
|
||||
try {
|
||||
setBusy(true);
|
||||
await lowerHand();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
} else {
|
||||
const myMembership = memberships.find((m) => m.sender === userId);
|
||||
if (!myMembership?.eventId) {
|
||||
logger.error("Cannot find own membership event");
|
||||
return;
|
||||
}
|
||||
const parentEventId = myMembership.eventId;
|
||||
try {
|
||||
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);
|
||||
} catch (ex) {
|
||||
logger.error("Failed to send reaction event", ex);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void raiseHand();
|
||||
}, [
|
||||
client,
|
||||
isHandRaised,
|
||||
memberships,
|
||||
rtcSession.room.roomId,
|
||||
userId,
|
||||
lowerHand,
|
||||
]);
|
||||
|
||||
return (
|
||||
<InnerButton
|
||||
disabled={busy}
|
||||
onClick={toggleRaisedHand}
|
||||
raised={isHandRaised}
|
||||
/>
|
||||
);
|
||||
}
|
||||
82
src/button/ReactionToggleButton.module.css
Normal file
82
src/button/ReactionToggleButton.module.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.raisedButton > svg {
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
.reactionPopupMenu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reactionPopupMenuModal {
|
||||
width: fit-content !important;
|
||||
top: 82vh !important;
|
||||
}
|
||||
|
||||
.reactionPopupMenuModal > div > div {
|
||||
padding-inline: var(--cpd-space-6x) !important;
|
||||
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
|
||||
}
|
||||
|
||||
.reactionPopupMenu menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--cpd-separator-spacing);
|
||||
}
|
||||
|
||||
.reactionPopupMenu section {
|
||||
height: fit-content;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.reactionPopupMenuItem {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reactionsMenu {
|
||||
min-height: 3em;
|
||||
}
|
||||
|
||||
.reactionButton {
|
||||
padding: 1em;
|
||||
font-size: 1.6em;
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
}
|
||||
|
||||
.verticalSeperator {
|
||||
background-color: var(--cpd-color-gray-800);
|
||||
width: 1px;
|
||||
height: auto;
|
||||
margin-left: var(--cpd-separator-spacing);
|
||||
margin-right: var(--cpd-separator-spacing);
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--cpd-separator-spacing);
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.searchForm > label {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: var(--cpd-space-3x);
|
||||
animation: grow-in 200ms;
|
||||
height: 2.5em;
|
||||
}
|
||||
|
||||
@keyframes grow-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
height: 2.5em;
|
||||
}
|
||||
}
|
||||
214
src/button/ReactionToggleButton.test.tsx
Normal file
214
src/button/ReactionToggleButton.test.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
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";
|
||||
|
||||
import {
|
||||
MockRoom,
|
||||
MockRTCSession,
|
||||
TestReactionsWrapper,
|
||||
} from "../utils/testReactions";
|
||||
import { ReactionToggleButton } from "./ReactionToggleButton";
|
||||
import { ElementCallReactionEventType } from "../reactions";
|
||||
|
||||
const memberUserIdAlice = "@alice:example.org";
|
||||
const memberEventAlice = "$membership-alice:example.org";
|
||||
|
||||
const membership: Record<string, string> = {
|
||||
[memberEventAlice]: memberUserIdAlice,
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
rtcSession,
|
||||
room,
|
||||
}: {
|
||||
rtcSession: MockRTCSession;
|
||||
room: MockRoom;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TestReactionsWrapper rtcSession={rtcSession}>
|
||||
<ReactionToggleButton
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
client={room.client}
|
||||
/>
|
||||
</TestReactionsWrapper>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
test("Can open menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Can raise hand", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.raise_hand"));
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
undefined,
|
||||
"m.reaction",
|
||||
{
|
||||
"m.relates_to": {
|
||||
event_id: memberEventAlice,
|
||||
key: "🖐️",
|
||||
rel_type: "m.annotation",
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Can lower hand", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.lower_hand"));
|
||||
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Can react with emoji", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, getByText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByText("🐶"));
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
undefined,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
event_id: memberEventAlice,
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
name: "dog",
|
||||
emoji: "🐶",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can search for and send emoji", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByText, container, getByLabelText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
// Search should autofocus.
|
||||
await user.keyboard("crickets");
|
||||
expect(container).toMatchSnapshot();
|
||||
await user.click(getByText("🦗"));
|
||||
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
undefined,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
event_id: memberEventAlice,
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
name: "crickets",
|
||||
emoji: "🦗",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can search for and send emoji with the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, getByPlaceholderText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
const searchField = getByPlaceholderText("reaction_search");
|
||||
// Search should autofocus.
|
||||
await user.keyboard("crickets");
|
||||
expect(container).toMatchSnapshot();
|
||||
act(() => {
|
||||
fireEvent.keyDown(searchField, { key: "Enter" });
|
||||
});
|
||||
expect(room.testSentEvents).toEqual([
|
||||
[
|
||||
undefined,
|
||||
ElementCallReactionEventType,
|
||||
{
|
||||
"m.relates_to": {
|
||||
event_id: memberEventAlice,
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
name: "crickets",
|
||||
emoji: "🦗",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test("Can close search", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
await user.click(getByLabelText("action.close_search"));
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("Can close search with the escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = new MockRoom(memberUserIdAlice);
|
||||
const rtcSession = new MockRTCSession(room, membership);
|
||||
const { getByLabelText, container, getByPlaceholderText } = render(
|
||||
<TestComponent rtcSession={rtcSession} room={room} />,
|
||||
);
|
||||
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
|
||||
await user.click(getByLabelText("action.open_search"));
|
||||
const searchField = getByPlaceholderText("reaction_search");
|
||||
act(() => {
|
||||
fireEvent.keyDown(searchField, { key: "Escape" });
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
348
src/button/ReactionToggleButton.tsx
Normal file
348
src/button/ReactionToggleButton.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button as CpdButton,
|
||||
Tooltip,
|
||||
Search,
|
||||
Form,
|
||||
Alert,
|
||||
} from "@vector-im/compound-web";
|
||||
import {
|
||||
SearchIcon,
|
||||
CloseIcon,
|
||||
RaisedHandSolidIcon,
|
||||
ReactionIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
ComponentPropsWithoutRef,
|
||||
FC,
|
||||
KeyboardEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} 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 { Modal } from "../Modal";
|
||||
|
||||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
raised: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
|
||||
<CpdButton
|
||||
className={classNames(raised && styles.raisedButton)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup
|
||||
aria-label={t("action.raise_hand_or_send_reaction")}
|
||||
kind={raised || open ? "primary" : "secondary"}
|
||||
iconOnly
|
||||
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export function ReactionPopupMenu({
|
||||
sendReaction,
|
||||
toggleRaisedHand,
|
||||
isHandRaised,
|
||||
canReact,
|
||||
errorText,
|
||||
}: {
|
||||
sendReaction: (reaction: ReactionOption) => void;
|
||||
toggleRaisedHand: () => void;
|
||||
errorText?: string;
|
||||
isHandRaised: boolean;
|
||||
canReact: boolean;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const onSearch = useCallback<ChangeEventHandler<HTMLInputElement>>((ev) => {
|
||||
ev.preventDefault();
|
||||
setSearchText(ev.target.value.trim().toLocaleLowerCase());
|
||||
}, []);
|
||||
|
||||
const filteredReactionSet = useMemo(
|
||||
() =>
|
||||
ReactionSet.filter(
|
||||
(reaction) =>
|
||||
!isSearching ||
|
||||
(!!searchText &&
|
||||
(reaction.name.startsWith(searchText) ||
|
||||
reaction.alias?.some((a) => a.startsWith(searchText)))),
|
||||
).slice(0, 6),
|
||||
[searchText, isSearching],
|
||||
);
|
||||
|
||||
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
|
||||
(ev) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
if (!canReact) {
|
||||
return;
|
||||
}
|
||||
if (filteredReactionSet.length !== 1) {
|
||||
return;
|
||||
}
|
||||
sendReaction(filteredReactionSet[0]);
|
||||
setIsSearching(false);
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[sendReaction, filteredReactionSet, canReact, setIsSearching],
|
||||
);
|
||||
const label = isHandRaised ? t("action.lower_hand") : t("action.raise_hand");
|
||||
return (
|
||||
<>
|
||||
{errorText && (
|
||||
<Alert
|
||||
className={styles.alert}
|
||||
type="critical"
|
||||
title={t("common.something_went_wrong")}
|
||||
>
|
||||
{errorText}
|
||||
</Alert>
|
||||
)}
|
||||
<div className={styles.reactionPopupMenu}>
|
||||
<section className={styles.handRaiseSection}>
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
kind={isHandRaised ? "primary" : "secondary"}
|
||||
aria-pressed={isHandRaised}
|
||||
aria-label={label}
|
||||
onClick={() => toggleRaisedHand()}
|
||||
iconOnly
|
||||
Icon={RaisedHandSolidIcon}
|
||||
/>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<div className={styles.verticalSeperator} />
|
||||
<section>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Form.Root className={styles.searchForm}>
|
||||
<Search
|
||||
required
|
||||
value={searchText}
|
||||
name="reactionSearch"
|
||||
placeholder={t("reaction_search")}
|
||||
onChange={onSearch}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
// This is a reasonable use of autofocus, we are focusing when
|
||||
// the search button is clicked (which matches the Element Web reaction picker)
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
<CpdButton
|
||||
Icon={CloseIcon}
|
||||
aria-label={t("action.close_search")}
|
||||
size="sm"
|
||||
kind="destructive"
|
||||
onClick={() => setIsSearching(false)}
|
||||
/>
|
||||
</Form.Root>
|
||||
</>
|
||||
) : null}
|
||||
<menu className={styles.reactionsMenu}>
|
||||
{filteredReactionSet.map((reaction) => (
|
||||
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
|
||||
<Tooltip label={reaction.name}>
|
||||
<CpdButton
|
||||
kind="secondary"
|
||||
className={styles.reactionButton}
|
||||
disabled={!canReact}
|
||||
onClick={() => sendReaction(reaction)}
|
||||
>
|
||||
{reaction.emoji}
|
||||
</CpdButton>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</menu>
|
||||
</section>
|
||||
{!isSearching ? (
|
||||
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
|
||||
<li key="search" className={styles.reactionPopupMenuItem}>
|
||||
<Tooltip label={t("common.search")}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={t("action.open_search")}
|
||||
Icon={SearchIcon}
|
||||
kind="tertiary"
|
||||
onClick={() => setIsSearching(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</li>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReactionToggleButtonProps {
|
||||
rtcSession: MatrixRTCSession;
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
client,
|
||||
rtcSession,
|
||||
}: ReactionToggleButtonProps): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const { raisedHands, lowerHand, 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>();
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
setErrorText(undefined);
|
||||
setShowReactionsMenu(false);
|
||||
} catch (ex) {
|
||||
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
|
||||
logger.error("Failed to send reaction", ex);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[memberships, client, userId, rtcSession],
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void raiseHand();
|
||||
}, [
|
||||
client,
|
||||
isHandRaised,
|
||||
memberships,
|
||||
lowerHand,
|
||||
rtcSession.room.roomId,
|
||||
userId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InnerButton
|
||||
disabled={busy}
|
||||
onClick={() => setShowReactionsMenu((show) => !show)}
|
||||
raised={isHandRaised}
|
||||
open={showReactionsMenu}
|
||||
/>
|
||||
<Modal
|
||||
open={showReactionsMenu}
|
||||
title={t("action.pick_reaction")}
|
||||
hideHeader
|
||||
classNameModal={styles.reactionPopupMenuModal}
|
||||
onDismiss={() => setShowReactionsMenu(false)}
|
||||
>
|
||||
<ReactionPopupMenu
|
||||
errorText={errorText}
|
||||
isHandRaised={isHandRaised}
|
||||
canReact={canReact}
|
||||
sendReaction={(reaction) => void sendRelation(reaction)}
|
||||
toggleRaisedHand={toggleRaisedHand}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
241
src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
Normal file
241
src/button/__snapshots__/ReactionToggleButton.test.tsx.snap
Normal file
@@ -0,0 +1,241 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Can close search 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":rec:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can close search with the escape key 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":rhh:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can lower hand 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r3i:"
|
||||
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can open menu 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r0:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can raise hand 1`] = `
|
||||
<div>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r1p:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can search for and send emoji 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":r74:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`Can search for and send emoji with the keyboard 1`] = `
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-aria-hidden="true"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-label="action.raise_hand_or_send_reaction"
|
||||
aria-labelledby=":ra3:"
|
||||
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -7,4 +7,4 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
export * from "./Button";
|
||||
export * from "./LinkButton";
|
||||
export * from "./RaisedHandToggleButton";
|
||||
export * from "./ReactionToggleButton";
|
||||
|
||||
Reference in New Issue
Block a user