/* 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, Separator, Search, Form, } from "@vector-im/compound-web"; import { SearchIcon, CloseIcon, RaisedHandSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ChangeEventHandler, ComponentPropsWithoutRef, FC, FormEventHandler, KeyboardEvent, 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"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; } const InnerButton: FC = ({ raised, ...props }) => { const { t } = useTranslation(); return ( ); }; export function ReactionPopupMenu({ sendReaction, toggleRaisedHand, isHandRaised, canReact, }: { sendReaction: (reaction: ReactionOption) => void; toggleRaisedHand: () => void; 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], ); return (
toggleRaisedHand()} > 🖐️
{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 { raisedHands, myReactionId, 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 canReact = !reactions[userId]; const sendRelation = useCallback( async (reaction: ReactionOption) => { 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); await client.sendEvent( rtcSession.room.roomId, ElementCallReactionEventType, { "m.relates_to": { rel_type: RelationType.Reference, event_id: parentEventId, }, emoji: reaction.emoji, name: reaction.name, }, ); // Do NOT close the menu after this. } catch (ex) { logger.error("Failed to send reaction", ex); } finally { setBusy(false); } }, [memberships, client, userId, rtcSession], ); const toggleRaisedHand = useCallback(() => { const raiseHand = async (): Promise => { if (isHandRaised) { if (!myReactionId) { logger.warn(`Hand raised but no reaction event to redact!`); return; } try { setBusy(true); await client.redactEvent(rtcSession.room.roomId, myReactionId); logger.debug("Redacted raise hand event"); } catch (ex) { logger.error("Failed to redact reaction event", myReactionId, ex); } 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); setShowReactionsMenu(false); } } }; void raiseHand(); }, [ client, isHandRaised, memberships, myReactionId, rtcSession.room.roomId, userId, ]); return ( <> setShowReactionsMenu((show) => !show)} raised={isHandRaised || showReactionsMenu} /> {showReactionsMenu && ( void sendRelation(reaction)} toggleRaisedHand={toggleRaisedHand} /> )} ); }