/* Copyright 2024 New Vector Ltd. Copyright 2019-2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { SyntheticEvent } from "react"; import classNames from "classnames"; import { MatrixEvent, MatrixEventEvent, Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; import { uniqBy } from "lodash"; import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import { _t } from "../../../languageHandler"; import { isContentActionable } from "../../../utils/EventUtils"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import ContextMenu, { aboveLeftOf, useContextMenu } from "../../structures/ContextMenu"; import ReactionPicker from "../emojipicker/ReactionPicker"; import ReactionsRowButton from "./ReactionsRowButton"; import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from "../elements/AccessibleButton"; import SettingsStore from "../../../settings/SettingsStore"; // The maximum number of reactions to initially show on a message. const MAX_ITEMS_WHEN_LIMITED = 8; export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode"); const ReactButton: React.FC = ({ mxEvent, reactions }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); let contextMenu: JSX.Element | undefined; if (menuDisplayed && button.current) { const buttonRect = button.current.getBoundingClientRect(); contextMenu = ( ); } return ( { e.preventDefault(); openMenu(); }} isExpanded={menuDisplayed} ref={button} /> {contextMenu} ); }; interface IProps { // The event we're displaying reactions for mxEvent: MatrixEvent; // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations | null | undefined; } interface IState { myReactions: MatrixEvent[] | null; showAll: boolean; } export default class ReactionsRow extends React.PureComponent { public static contextType = RoomContext; public declare context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { super(props, context); this.state = { myReactions: this.getMyReactions(), showAll: false, }; } public componentDidMount(): void { const { mxEvent, reactions } = this.props; if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { mxEvent.once(MatrixEventEvent.Decrypted, this.onDecrypted); } if (reactions) { reactions.on(RelationsEvent.Add, this.onReactionsChange); reactions.on(RelationsEvent.Remove, this.onReactionsChange); reactions.on(RelationsEvent.Redaction, this.onReactionsChange); } } public componentWillUnmount(): void { const { mxEvent, reactions } = this.props; mxEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted); if (reactions) { reactions.off(RelationsEvent.Add, this.onReactionsChange); reactions.off(RelationsEvent.Remove, this.onReactionsChange); reactions.off(RelationsEvent.Redaction, this.onReactionsChange); } } public componentDidUpdate(prevProps: IProps): void { if (this.props.reactions && prevProps.reactions !== this.props.reactions) { this.props.reactions.on(RelationsEvent.Add, this.onReactionsChange); this.props.reactions.on(RelationsEvent.Remove, this.onReactionsChange); this.props.reactions.on(RelationsEvent.Redaction, this.onReactionsChange); this.onReactionsChange(); } } private onDecrypted = (): void => { // Decryption changes whether the event is actionable this.forceUpdate(); }; private onReactionsChange = (): void => { // TODO: Call `onHeightChanged` as needed this.setState({ myReactions: this.getMyReactions(), }); // Using `forceUpdate` for the moment, since we know the overall set of reactions // has changed (this is triggered by events for that purpose only) and // `PureComponent`s shallow state / props compare would otherwise filter this out. this.forceUpdate(); }; private getMyReactions(): MatrixEvent[] | null { const reactions = this.props.reactions; if (!reactions) { return null; } const userId = this.context.room?.client.getUserId(); if (!userId) return null; const myReactions = reactions.getAnnotationsBySender()?.[userId]; if (!myReactions) { return null; } return [...myReactions.values()]; } private onShowAllClick = (): void => { this.setState({ showAll: true, }); }; public render(): React.ReactNode { const { mxEvent, reactions } = this.props; const { myReactions, showAll } = this.state; if (!reactions || !isContentActionable(mxEvent)) { return null; } const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); let items = reactions .getSortedAnnotationsByKey() ?.map(([content, events]) => { const count = events.size; if (!count) { return null; } // Deduplicate the events as per the spec https://spec.matrix.org/v1.7/client-server-api/#annotations-client-behaviour // This isn't done by the underlying data model as applications may still need access to the whole list of events // for moderation purposes. const deduplicatedEvents = uniqBy([...events], (e) => e.getSender()); const myReactionEvent = myReactions?.find((mxEvent) => { if (mxEvent.isRedacted()) { return false; } return mxEvent.getRelation()?.key === content; }); return ( ); }) .filter((item) => !!item); if (!items?.length) return null; // Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items. // The "+ 1" ensure that the "show all" reveals something that takes up // more space than the button itself. let showAllButton: JSX.Element | undefined; if (items.length > MAX_ITEMS_WHEN_LIMITED + 1 && !showAll) { items = items.slice(0, MAX_ITEMS_WHEN_LIMITED); showAllButton = ( {_t("action|show_all")} ); } let addReactionButton: JSX.Element | undefined; if (this.context.canReact) { addReactionButton = ; } return (
{items} {showAllButton} {addReactionButton}
); } }