From 5d88c52e307261ac1dc9cbc5933edddd6f589986 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Fri, 8 Nov 2024 17:36:40 +0000 Subject: [PATCH] Support for generic reactions (#2708) * Initial support for Hand Raise feature Signed-off-by: Milton Moura * Refactored to use reaction and redaction events Signed-off-by: Milton Moura * Replacing button svg with raised hand emoji Signed-off-by: Milton Moura * SpotlightTile should not duplicate the raised hand Signed-off-by: Milton Moura * 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 * 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 * Removing RaiseHand.svg * Check for reaction & redaction capabilities in widget mode Signed-off-by: Milton Moura * Fix failing GridTile test Signed-off-by: Milton Moura * 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 Co-authored-by: Milton Moura Co-authored-by: fkwp --- package.json | 3 +- public/locales/en-GB/app.json | 18 +- src/@types/matrix-js-sdk.d.ts | 18 + src/Modal.tsx | 60 ++- src/button/RaisedHandToggleButton.tsx | 126 ------- src/button/ReactionToggleButton.module.css | 82 +++++ src/button/ReactionToggleButton.test.tsx | 214 +++++++++++ src/button/ReactionToggleButton.tsx | 348 ++++++++++++++++++ .../ReactionToggleButton.test.tsx.snap | 241 ++++++++++++ src/button/index.ts | 2 +- src/main.tsx | 2 + src/reactions/RaisedHandIndicator.tsx | 30 +- ...odule.css => ReactionIndicator.module.css} | 35 +- src/reactions/ReactionIndicator.tsx | 41 +++ .../RaisedHandIndicator.test.tsx.snap | 18 +- src/reactions/index.ts | 181 +++++++++ src/room/InCallView.module.css | 45 +++ src/room/InCallView.tsx | 33 +- src/room/ReactionAudioRenderer.test.tsx | 155 ++++++++ src/room/ReactionAudioRenderer.tsx | 68 ++++ src/settings/PreferencesSettingsTab.tsx | 47 ++- src/settings/settings.ts | 12 +- src/sound/LICENCE.md | 22 ++ src/sound/reactions/cat.mp3 | Bin 0 -> 23040 bytes src/sound/reactions/cat.ogg | Bin 0 -> 18482 bytes src/sound/reactions/clap.mp3 | Bin 0 -> 137280 bytes src/sound/reactions/clap.ogg | Bin 0 -> 51642 bytes src/sound/reactions/crickets.mp3 | Bin 0 -> 129566 bytes src/sound/reactions/crickets.ogg | Bin 0 -> 30497 bytes src/sound/reactions/deer.mp3 | Bin 0 -> 18284 bytes src/sound/reactions/deer.ogg | Bin 0 -> 17254 bytes src/sound/reactions/dog.mp3 | Bin 0 -> 11702 bytes src/sound/reactions/dog.ogg | Bin 0 -> 7104 bytes src/sound/reactions/generic.mp3 | Bin 0 -> 56842 bytes src/sound/reactions/generic.ogg | Bin 0 -> 20317 bytes src/sound/reactions/lightbulb.mp3 | Bin 0 -> 36096 bytes src/sound/reactions/lightbulb.ogg | Bin 0 -> 9950 bytes src/sound/reactions/party.mp3 | Bin 0 -> 40800 bytes src/sound/reactions/party.ogg | Bin 0 -> 14672 bytes src/sound/reactions/rock.mp3 | Bin 0 -> 102024 bytes src/sound/reactions/rock.ogg | Bin 0 -> 97622 bytes src/tile/GridTile.tsx | 6 +- src/tile/MediaView.tsx | 24 +- src/useReactions.test.tsx | 227 ++++-------- src/useReactions.tsx | 111 +++++- src/utils/testReactions.tsx | 199 ++++++++++ src/widget.ts | 2 + yarn.lock | 17 +- 48 files changed, 2000 insertions(+), 387 deletions(-) create mode 100644 src/@types/matrix-js-sdk.d.ts delete mode 100644 src/button/RaisedHandToggleButton.tsx create mode 100644 src/button/ReactionToggleButton.module.css create mode 100644 src/button/ReactionToggleButton.test.tsx create mode 100644 src/button/ReactionToggleButton.tsx create mode 100644 src/button/__snapshots__/ReactionToggleButton.test.tsx.snap rename src/reactions/{RaisedHandIndicator.module.css => ReactionIndicator.module.css} (65%) create mode 100644 src/reactions/ReactionIndicator.tsx create mode 100644 src/reactions/index.ts create mode 100644 src/room/ReactionAudioRenderer.test.tsx create mode 100644 src/room/ReactionAudioRenderer.tsx create mode 100644 src/sound/LICENCE.md create mode 100644 src/sound/reactions/cat.mp3 create mode 100644 src/sound/reactions/cat.ogg create mode 100644 src/sound/reactions/clap.mp3 create mode 100644 src/sound/reactions/clap.ogg create mode 100644 src/sound/reactions/crickets.mp3 create mode 100644 src/sound/reactions/crickets.ogg create mode 100644 src/sound/reactions/deer.mp3 create mode 100644 src/sound/reactions/deer.ogg create mode 100644 src/sound/reactions/dog.mp3 create mode 100644 src/sound/reactions/dog.ogg create mode 100644 src/sound/reactions/generic.mp3 create mode 100644 src/sound/reactions/generic.ogg create mode 100644 src/sound/reactions/lightbulb.mp3 create mode 100644 src/sound/reactions/lightbulb.ogg create mode 100644 src/sound/reactions/party.mp3 create mode 100644 src/sound/reactions/party.ogg create mode 100644 src/sound/reactions/rock.mp3 create mode 100644 src/sound/reactions/rock.ogg create mode 100644 src/utils/testReactions.tsx diff --git a/package.json b/package.json index 57db9eb5..82c7eedc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@formatjs/intl-durationformat": "^0.6.1", + "@formatjs/intl-segmenter": "^11.7.3", "@livekit/components-core": "^0.11.0", "@livekit/components-react": "^2.0.0", "@opentelemetry/api": "^1.4.0", @@ -57,7 +58,7 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@use-gesture/react": "^10.2.11", - "@vector-im/compound-design-tokens": "^1.0.0", + "@vector-im/compound-design-tokens": "^1.9.1", "@vector-im/compound-web": "^6.0.0", "@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-react": "^4.0.1", diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 02dd7740..7a390c2f 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -4,11 +4,17 @@ }, "action": { "close": "Close", + "close_search": "Close search", "copy_link": "Copy link", "edit": "Edit", "go": "Go", "invite": "Invite", + "lower_hand": "Lower hand", "no": "No", + "open_search": "Open search", + "pick_reaction": "Pick reaction", + "raise_hand": "Raise hand", + "raise_hand_or_send_reaction": "Raise hand or send reaction", "register": "Register", "remove": "Remove", "sign_in": "Sign in", @@ -55,8 +61,10 @@ "password": "Password", "preferences": "Preferences", "profile": "Profile", - "raise_hand": "Raise hand", + "reaction": "Reaction", + "search": "Search", "settings": "Settings", + "something_went_wrong": "Something went wrong", "unencrypted": "Not encrypted", "username": "Username", "video": "Video" @@ -120,6 +128,7 @@ "rageshake_sending": "Sending…", "rageshake_sending_logs": "Sending debug logs…", "rageshake_sent": "Thanks!", + "reaction_search": "Search reactions…", "recaptcha_caption": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)", "recaptcha_dismissed": "Recaptcha dismissed", "recaptcha_not_loaded": "Recaptcha not loaded", @@ -147,6 +156,13 @@ "feedback_tab_title": "Feedback", "more_tab_title": "More", "opt_in_description": "<0><1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.", + "preferences_tab": { + "reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.", + "reactions_play_sound_label": "Play reaction sounds", + "reactions_show_description": "Show reactions", + "reactions_show_label": "Show an animation when anyone sends a reaction.", + "reactions_title": "Reactions" + }, "preferences_tab_body": "Here you can configure extra options for an improved experience", "preferences_tab_h4": "Preferences", "preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand", diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts new file mode 100644 index 00000000..dc27b1ef --- /dev/null +++ b/src/@types/matrix-js-sdk.d.ts @@ -0,0 +1,18 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { + ElementCallReactionEventType, + ECallReactionEventContent, +} from "../reactions"; + +// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types +declare module "matrix-js-sdk/src/types" { + export interface TimelineEvents { + [ElementCallReactionEventType]: ECallReactionEventContent; + } +} diff --git a/src/Modal.tsx b/src/Modal.tsx index 6e9de90b..63d5c50a 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -27,8 +27,21 @@ import { useMediaQuery } from "./useMediaQuery"; export interface Props { title: string; + /** + * Hide the modal header. Used for smaller popups where the context is readily apparent. + * A title should still be specified for users using assistive technology. + */ + hideHeader?: boolean; children: ReactNode; className?: string; + /** + * Class name to be used when in drawer mode (touchscreen). + */ + classNameDrawer?: string; + /** + * Class name to be used when in modal mode (desktop). + */ + classNameModal?: string; /** * The controlled open state of the modal. */ @@ -54,8 +67,11 @@ export interface Props { */ export const Modal: FC = ({ title, + hideHeader, children, className, + classNameDrawer, + classNameModal, open, onDismiss, tabbed, @@ -84,6 +100,7 @@ export const Modal: FC = ({ = ({ ); } else { + const titleNode = ( + + + {title} + + + ); + const header = ( +
+ {titleNode} + {onDismiss !== undefined && ( + + + + )} +
+ ); + return ( @@ -126,6 +165,7 @@ export const Modal: FC = ({ = ({ )} >
-
- - - {title} - - - {onDismiss !== undefined && ( - - - - )} -
+ {!hideHeader ? header : null} + {hideHeader ? ( + {titleNode} + ) : null}
{children}
diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx deleted file mode 100644 index 42006f6a..00000000 --- a/src/button/RaisedHandToggleButton.tsx +++ /dev/null @@ -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 = ({ raised, ...props }) => { - const { t } = useTranslation(); - - return ( - - -

- βœ‹ -

-
-
- ); -}; - -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 => { - 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 ( - - ); -} diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css new file mode 100644 index 00000000..f097c917 --- /dev/null +++ b/src/button/ReactionToggleButton.module.css @@ -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; + } +} diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx new file mode 100644 index 00000000..b13b74fa --- /dev/null +++ b/src/button/ReactionToggleButton.test.tsx @@ -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 = { + [memberEventAlice]: memberUserIdAlice, +}; + +function TestComponent({ + rtcSession, + room, +}: { + rtcSession: MockRTCSession; + room: MockRoom; +}): ReactNode { + return ( + + + + + + ); +} + +test("Can open menu", async () => { + const user = userEvent.setup(); + const room = new MockRoom(memberUserIdAlice); + const rtcSession = new MockRTCSession(room, membership); + const { getByLabelText, container } = render( + , + ); + await user.click(getByLabelText("action.raise_hand_or_send_reaction")); + expect(container).toMatchSnapshot(); +}); + +test("Can raise hand", async () => { + const user = userEvent.setup(); + const room = new MockRoom(memberUserIdAlice); + const rtcSession = new MockRTCSession(room, membership); + const { getByLabelText, container } = render( + , + ); + await user.click(getByLabelText("action.raise_hand_or_send_reaction")); + await user.click(getByLabelText("action.raise_hand")); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); +}); diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx new file mode 100644 index 00000000..583a1cd2 --- /dev/null +++ b/src/button/ReactionToggleButton.tsx @@ -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 = ({ raised, open, ...props }) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +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>((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>( + (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 && ( + + {errorText} + + )} +
+
+ + toggleRaisedHand()} + iconOnly + Icon={RaisedHandSolidIcon} + /> + +
+
+
+ {isSearching ? ( + <> + + + setIsSearching(false)} + /> + + + ) : null} + + {filteredReactionSet.map((reaction) => ( +
  • + + sendReaction(reaction)} + > + {reaction.emoji} + + +
  • + ))} +
    +
    + {!isSearching ? ( +
    +
  • + + setIsSearching(true)} + /> + +
  • +
    + ) : null} +
    + + ); +} + +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(); + + 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 => { + 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 ( + <> + setShowReactionsMenu((show) => !show)} + raised={isHandRaised} + open={showReactionsMenu} + /> + setShowReactionsMenu(false)} + > + void sendRelation(reaction)} + toggleRaisedHand={toggleRaisedHand} + /> + + + ); +} diff --git a/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap new file mode 100644 index 00000000..bee0bdb1 --- /dev/null +++ b/src/button/__snapshots__/ReactionToggleButton.test.tsx.snap @@ -0,0 +1,241 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Can close search 1`] = ` + +`; + +exports[`Can close search with the escape key 1`] = ` +
    + +
    +`; + +exports[`Can lower hand 1`] = ` +
    + +
    +`; + +exports[`Can open menu 1`] = ` + +`; + +exports[`Can raise hand 1`] = ` +
    + +
    +`; + +exports[`Can search for and send emoji 1`] = ` + +`; + +exports[`Can search for and send emoji with the keyboard 1`] = ` + +`; diff --git a/src/button/index.ts b/src/button/index.ts index e4e7cfad..07b19866 100644 --- a/src/button/index.ts +++ b/src/button/index.ts @@ -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"; diff --git a/src/main.tsx b/src/main.tsx index f847e21a..d4a4539b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,8 @@ import { setLogExtension as setLKLogExtension, setLogLevel as setLKLogLevel, } from "livekit-client"; +import "@formatjs/intl-segmenter/polyfill"; +import "@formatjs/intl-durationformat/polyfill"; import { App } from "./App"; import { init as initRageshake } from "./settings/rageshake"; diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index ddf66dec..cfc83ab8 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -12,11 +12,10 @@ import { useEffect, useState, } from "react"; -import classNames from "classnames"; -import "@formatjs/intl-durationformat/polyfill"; import { DurationFormat } from "@formatjs/intl-durationformat"; +import { useTranslation } from "react-i18next"; -import styles from "./RaisedHandIndicator.module.css"; +import { ReactionIndicator } from "./ReactionIndicator"; const durationFormatter = new DurationFormat(undefined, { minutesDisplay: "always", @@ -36,6 +35,7 @@ export function RaisedHandIndicator({ showTimer?: boolean; onClick?: () => void; }): ReactNode { + const { t } = useTranslation(); const [raisedHandDuration, setRaisedHandDuration] = useState(""); const clickCallback = useCallback>( @@ -76,29 +76,19 @@ export function RaisedHandIndicator({ } const content = ( -
    -
    - - βœ‹ - -
    + {showTimer &&

    {raisedHandDuration}

    } -
    + ); if (onClick) { return (