From fe84501e95300beaf984df46bbf0010adc2a28a6 Mon Sep 17 00:00:00 2001 From: Zack Date: Mon, 2 Mar 2026 14:59:04 +0100 Subject: [PATCH] Move ReactionRow To Shared Components MVVM (#32634) * Init shared component structure * Storybook implementation * Add snapshots of storybook examples * ViewModel Creation + Implementation In EventTile.tsx * Prettier * Update HTML snapshot * Add onhover pointer on bottons * Added compound web tooltip * Removed possible of undefined on label * Update snapshots * Update setters to use merge instead of updating full snapshot * adapt view model test for setters change * Actions should be passed to viewmodel fix * replace ReactionsRowWrapper forceRender with explicit reaction state * Update snapshot --- .../views/messages/ReactionsRow.tsx | 282 ------------------ .../src/components/views/rooms/EventTile.tsx | 266 ++++++++++++++++- .../ReactionsRowButtonTooltipViewModel.ts | 2 +- .../ReactionsRowButtonViewModel.ts | 2 +- .../message-body/ReactionsRowViewModel.ts | 174 +++++++++++ .../message-body/reactionShortcode.ts | 10 + .../__snapshots__/HTMLExport-test.ts.snap | 2 +- .../ReactionsRowViewModel-test.tsx | 106 +++++++ .../add-reaction-button-active-auto.png | Bin 0 -> 7506 bytes ...eaction-button-hidden-until-hover-auto.png | Bin 0 -> 7085 bytes .../ReactionsRow.stories.tsx/default-auto.png | Bin 0 -> 7533 bytes .../ReactionsRow.stories.tsx/hidden-auto.png | Bin 0 -> 3522 bytes .../with-show-all-button-auto.png | Bin 0 -> 8011 bytes packages/shared-components/src/index.ts | 1 + .../ReactionRow/ReactionsRow.module.css | 70 +++++ .../ReactionRow/ReactionsRow.stories.tsx | 119 ++++++++ .../ReactionRow/ReactionsRow.test.tsx | 90 ++++++ .../ReactionRow/ReactionsRowView.tsx | 144 +++++++++ .../__snapshots__/ReactionsRow.test.tsx.snap | 183 ++++++++++++ .../src/message-body/ReactionRow/index.tsx | 13 + .../ReactionsRowButton.module.css | 1 + 21 files changed, 1175 insertions(+), 290 deletions(-) delete mode 100644 apps/web/src/components/views/messages/ReactionsRow.tsx create mode 100644 apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts create mode 100644 apps/web/src/viewmodels/message-body/reactionShortcode.ts create mode 100644 apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-hidden-until-hover-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/hidden-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/with-show-all-button-auto.png create mode 100644 packages/shared-components/src/message-body/ReactionRow/ReactionsRow.module.css create mode 100644 packages/shared-components/src/message-body/ReactionRow/ReactionsRow.stories.tsx create mode 100644 packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx create mode 100644 packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx create mode 100644 packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap create mode 100644 packages/shared-components/src/message-body/ReactionRow/index.tsx diff --git a/apps/web/src/components/views/messages/ReactionsRow.tsx b/apps/web/src/components/views/messages/ReactionsRow.tsx deleted file mode 100644 index ef21ad9e82..0000000000 --- a/apps/web/src/components/views/messages/ReactionsRow.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/* -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 OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { useEffect, type JSX, type SyntheticEvent } from "react"; -import classNames from "classnames"; -import { type MatrixEvent, MatrixEventEvent, type Relations, RelationsEvent } from "matrix-js-sdk/src/matrix"; -import { uniqBy } from "lodash"; -import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; -import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { ReactionsRowButtonView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; - -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 RoomContext from "../../../contexts/RoomContext"; -import AccessibleButton from "../elements/AccessibleButton"; -import SettingsStore from "../../../settings/SettingsStore"; -import { ReactionsRowButtonViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonViewModel"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; - -// 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 ReactionsRowButtonItemProps { - mxEvent: MatrixEvent; - content: string; - count: number; - reactionEvents: MatrixEvent[]; - myReactionEvent?: MatrixEvent; - disabled?: boolean; - customReactionImagesEnabled?: boolean; -} - -const ReactionsRowButtonItem: React.FC = (props) => { - const client = useMatrixClientContext(); - - const vm = useCreateAutoDisposedViewModel( - () => - new ReactionsRowButtonViewModel({ - client, - mxEvent: props.mxEvent, - content: props.content, - count: props.count, - reactionEvents: props.reactionEvents, - myReactionEvent: props.myReactionEvent, - disabled: props.disabled, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }), - ); - - useEffect(() => { - vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); - }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); - - useEffect(() => { - vm.setCount(props.count); - }, [props.count, vm]); - - useEffect(() => { - vm.setMyReactionEvent(props.myReactionEvent); - }, [props.myReactionEvent, vm]); - - useEffect(() => { - vm.setDisabled(props.disabled); - }, [props.disabled, vm]); - - return ; -}; - -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; - declare public 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 => { - 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} -
- ); - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 47741d3abb..a6b7b7c326 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -7,7 +7,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, useContext, useEffect, type JSX, type Ref, type MouseEvent, type ReactNode } from "react"; +import React, { + createRef, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type JSX, + type Ref, + type MouseEvent, + type ReactNode, +} from "react"; import classNames from "classnames"; import { EventStatus, @@ -19,6 +30,7 @@ import { type Relations, type RelationType, type Room, + RelationsEvent, RoomEvent, type RoomMember, type Thread, @@ -34,12 +46,15 @@ import { type UserVerificationStatus, } from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; -import { uniqueId } from "lodash"; +import { uniqueId, uniqBy } from "lodash"; import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView, MessageTimestampView, + ReactionsRowButtonView, + ReactionsRowView, + useViewModel, } from "@element-hq/web-shared-components"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; @@ -50,7 +65,7 @@ import { Layout } from "../../../settings/enums/Layout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import { aboveRightOf } from "../../structures/ContextMenu"; +import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu"; import { objectHasDiff } from "../../../utils/objects"; import type EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; @@ -64,8 +79,9 @@ import MemberAvatar from "../avatars/MemberAvatar"; import SenderProfile from "../messages/SenderProfile"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; -import ReactionsRow from "../messages/ReactionsRow"; +import ReactionPicker from "../emojipicker/ReactionPicker"; import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; +import { isContentActionable } from "../../../utils/EventUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type ButtonEvent } from "../elements/AccessibleButton"; @@ -92,10 +108,14 @@ import { ElementCallEventType } from "../../../call-types"; import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel"; import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx"; +import SettingsStore from "../../../settings/SettingsStore"; import { MessageTimestampViewModel, type MessageTimestampViewModelProps, } from "../../../viewmodels/message-body/MessageTimestampViewModel.ts"; +import { ReactionsRowButtonViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonViewModel"; +import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel } from "../../../viewmodels/message-body/ReactionsRowViewModel"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; export type GetRelationsForEvent = ( eventId: string, @@ -1196,7 +1216,7 @@ export class UnwrappedEventTile extends React.Component let reactionsRow: JSX.Element | undefined; if (!isRedacted) { reactionsRow = ( - ); } + +interface ReactionsRowButtonItemProps { + mxEvent: MatrixEvent; + content: string; + count: number; + reactionEvents: MatrixEvent[]; + myReactionEvent?: MatrixEvent; + disabled?: boolean; + customReactionImagesEnabled?: boolean; +} + +function ReactionsRowButtonItem(props: Readonly): JSX.Element { + const client = useMatrixClientContext(); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowButtonViewModel({ + client, + mxEvent: props.mxEvent, + content: props.content, + count: props.count, + reactionEvents: props.reactionEvents, + myReactionEvent: props.myReactionEvent, + disabled: props.disabled, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }), + ); + + useEffect(() => { + vm.setReactionData(props.content, props.reactionEvents, props.customReactionImagesEnabled); + }, [props.content, props.reactionEvents, props.customReactionImagesEnabled, vm]); + + useEffect(() => { + vm.setCount(props.count); + }, [props.count, vm]); + + useEffect(() => { + vm.setMyReactionEvent(props.myReactionEvent); + }, [props.myReactionEvent, vm]); + + useEffect(() => { + vm.setDisabled(props.disabled); + }, [props.disabled, vm]); + + return ; +} + +interface ReactionGroup { + content: string; + events: MatrixEvent[]; +} + +const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] => + reactions + ?.getSortedAnnotationsByKey() + ?.map(([content, events]) => ({ + content, + events: [...events], + })) + .filter(({ events }) => events.length > 0) ?? []; + +const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => { + if (!reactions || !userId) { + return null; + } + + const myReactions = reactions.getAnnotationsBySender()?.[userId]; + if (!myReactions) { + return null; + } + + return [...myReactions.values()]; +}; + +interface ReactionsRowWrapperProps { + mxEvent: MatrixEvent; + reactions?: Relations | null; +} + +function ReactionsRowWrapper({ mxEvent, reactions }: Readonly): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }), + ); + + const openReactionMenu = useCallback((event: React.MouseEvent): void => { + setMenuAnchorRect(event.currentTarget.getBoundingClientRect()); + setMenuDisplayed(true); + }, []); + + const closeReactionMenu = useCallback((): void => { + setMenuDisplayed(false); + }, []); + + const updateReactionsState = useCallback((): void => { + const nextReactionGroups = getReactionGroups(reactions); + setReactionGroups(nextReactionGroups); + setMyReactions(getMyReactions(reactions, userId)); + vm.setReactionGroupCount(nextReactionGroups.length); + }, [reactions, userId, vm]); + + useEffect(() => { + vm.setActionable(isContentActionable(mxEvent)); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setCanReact(roomContext.canReact); + if (!roomContext.canReact && menuDisplayed) { + setMenuDisplayed(false); + } + }, [roomContext.canReact, menuDisplayed, vm]); + + useEffect(() => { + vm.setAddReactionHandlers({ + onAddReactionClick: openReactionMenu, + onAddReactionContextMenu: openReactionMenu, + }); + }, [openReactionMenu, vm]); + + useEffect(() => { + vm.setAddReactionButtonActive(menuDisplayed); + }, [menuDisplayed, vm]); + + useEffect(() => { + updateReactionsState(); + }, [updateReactionsState]); + + useEffect(() => { + if (!reactions) return; + + reactions.on(RelationsEvent.Add, updateReactionsState); + reactions.on(RelationsEvent.Remove, updateReactionsState); + reactions.on(RelationsEvent.Redaction, updateReactionsState); + + return () => { + reactions.off(RelationsEvent.Add, updateReactionsState); + reactions.off(RelationsEvent.Remove, updateReactionsState); + reactions.off(RelationsEvent.Redaction, updateReactionsState); + }; + }, [reactions, updateReactionsState]); + + useEffect(() => { + const onDecrypted = (): void => { + vm.setActionable(isContentActionable(mxEvent)); + }; + + if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) { + mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted); + } + + return () => { + mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mxEvent, vm]); + + const snapshot = useViewModel(vm); + const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images"); + const items = useMemo((): JSX.Element[] | undefined => { + const mappedItems = reactionGroups.map(({ content, events }) => { + // Deduplicate reaction events by sender per Matrix spec. + const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender()); + const myReactionEvent = myReactions?.find((reactionEvent) => { + if (reactionEvent.isRedacted()) { + return false; + } + return reactionEvent.getRelation()?.key === content; + }); + + return ( + + ); + }); + + if (!mappedItems.length) { + return undefined; + } + + return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems; + }, [ + reactionGroups, + myReactions, + mxEvent, + customReactionImagesEnabled, + roomContext.canReact, + roomContext.canSelfRedact, + snapshot.showAllButtonVisible, + ]); + + useEffect(() => { + vm.setChildren(items); + }, [items, vm]); + + if (!snapshot.isVisible || !items?.length) { + return null; + } + + let contextMenu: JSX.Element | undefined; + if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) { + contextMenu = ( + + + + ); + } + + return ( + <> + + {contextMenu} + + ); +} diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts index baa7b77855..ab3ce8204d 100644 --- a/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts +++ b/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts @@ -15,7 +15,7 @@ import { import { _t } from "../../languageHandler"; import { formatList } from "../../utils/FormattingUtils"; import { unicodeToShortcode } from "../../HtmlUtils"; -import { REACTION_SHORTCODE_KEY } from "../../components/views/messages/ReactionsRow"; +import { REACTION_SHORTCODE_KEY } from "./reactionShortcode"; export interface ReactionsRowButtonTooltipViewModelProps { /** diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts index 6435a38d6b..6f86c71104 100644 --- a/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts +++ b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts @@ -16,8 +16,8 @@ import { mediaFromMxc } from "../../customisations/Media"; import { _t } from "../../languageHandler"; import { formatList } from "../../utils/FormattingUtils"; import dis from "../../dispatcher/dispatcher"; -import { REACTION_SHORTCODE_KEY } from "../../components/views/messages/ReactionsRow"; import { ReactionsRowButtonTooltipViewModel } from "./ReactionsRowButtonTooltipViewModel"; +import { REACTION_SHORTCODE_KEY } from "./reactionShortcode"; export interface ReactionsRowButtonViewModelProps { /** diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts new file mode 100644 index 0000000000..48443b2ea2 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type MouseEvent, type MouseEventHandler, type ReactNode } from "react"; +import { + BaseViewModel, + type ReactionsRowViewSnapshot, + type ReactionsRowViewModel as ReactionsRowViewModelInterface, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../languageHandler"; + +export const MAX_ITEMS_WHEN_LIMITED = 8; + +export interface ReactionsRowViewModelProps { + /** + * Whether the current event is actionable for reactions. + */ + isActionable: boolean; + /** + * Number of reaction keys with at least one event. + */ + reactionGroupCount: number; + /** + * Whether the current user can add reactions. + */ + canReact: boolean; + /** + * Whether the add-reaction context menu is currently open. + */ + addReactionButtonActive?: boolean; + /** + * Optional callback invoked when the add-reaction button is clicked. + */ + onAddReactionClick?: MouseEventHandler; + /** + * Optional callback invoked on add-reaction button context-menu. + */ + onAddReactionContextMenu?: MouseEventHandler; + /** + * Reaction row children (typically reaction buttons). + */ + children?: ReactNode; +} + +interface InternalProps extends ReactionsRowViewModelProps { + showAll: boolean; +} + +export class ReactionsRowViewModel + extends BaseViewModel + implements ReactionsRowViewModelInterface +{ + private static readonly computeDerivedSnapshot = ( + props: InternalProps, + ): Pick< + ReactionsRowViewSnapshot, + "isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive" | "children" + > => ({ + isVisible: props.isActionable && props.reactionGroupCount > 0, + showAllButtonVisible: props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !props.showAll, + showAddReactionButton: props.canReact, + addReactionButtonActive: !!props.addReactionButtonActive, + children: props.children, + }); + + private static readonly computeSnapshot = (props: InternalProps): ReactionsRowViewSnapshot => ({ + ariaLabel: _t("common|reactions"), + className: "mx_ReactionsRow", + showAllButtonLabel: _t("action|show_all"), + addReactionButtonLabel: _t("timeline|reactions|add_reaction_prompt"), + addReactionButtonVisible: false, + ...ReactionsRowViewModel.computeDerivedSnapshot(props), + }); + + public constructor(props: ReactionsRowViewModelProps) { + const internalProps: InternalProps = { + ...props, + showAll: false, + }; + super(internalProps, ReactionsRowViewModel.computeSnapshot(internalProps)); + } + + public setActionable(isActionable: boolean): void { + this.props = { + ...this.props, + isActionable, + }; + + const isVisible = this.props.isActionable && this.props.reactionGroupCount > 0; + + this.snapshot.merge({ isVisible }); + } + + public setReactionGroupCount(reactionGroupCount: number): void { + this.props = { + ...this.props, + reactionGroupCount, + }; + + const nextIsVisible = this.props.isActionable && this.props.reactionGroupCount > 0; + const nextShowAllButtonVisible = + this.props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !this.props.showAll; + const updates: Partial = {}; + + if (this.snapshot.current.isVisible !== nextIsVisible) { + updates.isVisible = nextIsVisible; + } + if (this.snapshot.current.showAllButtonVisible !== nextShowAllButtonVisible) { + updates.showAllButtonVisible = nextShowAllButtonVisible; + } + if (Object.keys(updates).length > 0) { + this.snapshot.merge(updates); + } + } + + public setCanReact(canReact: boolean): void { + this.props = { + ...this.props, + canReact, + }; + + this.snapshot.merge({ showAddReactionButton: canReact }); + } + + public setAddReactionButtonActive(addReactionButtonActive: boolean): void { + this.props = { + ...this.props, + addReactionButtonActive, + }; + + this.snapshot.merge({ addReactionButtonActive }); + } + + public setChildren(children?: ReactNode): void { + this.props = { + ...this.props, + children, + }; + + this.snapshot.merge({ children }); + } + + public setAddReactionHandlers({ + onAddReactionClick, + onAddReactionContextMenu, + }: Pick): void { + this.props = { + ...this.props, + onAddReactionClick, + onAddReactionContextMenu, + }; + } + + public onShowAllClick = (): void => { + this.props = { + ...this.props, + showAll: true, + }; + this.snapshot.merge({ showAllButtonVisible: false }); + }; + + public onAddReactionClick = (event: MouseEvent): void => { + this.props.onAddReactionClick?.(event); + }; + + public onAddReactionContextMenu = (event: MouseEvent): void => { + this.props.onAddReactionContextMenu?.(event); + }; +} diff --git a/apps/web/src/viewmodels/message-body/reactionShortcode.ts b/apps/web/src/viewmodels/message-body/reactionShortcode.ts new file mode 100644 index 0000000000..ad90537506 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/reactionShortcode.ts @@ -0,0 +1,10 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; + +export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode"); diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index 19d065b3a5..a84fc3f724 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx b/apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx new file mode 100644 index 0000000000..46c57d2b21 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type MouseEvent } from "react"; + +import { ReactionsRowViewModel } from "../../../src/viewmodels/message-body/ReactionsRowViewModel"; + +describe("ReactionsRowViewModel", () => { + const createVm = ( + overrides?: Partial[0]>, + ): ReactionsRowViewModel => + new ReactionsRowViewModel({ + isActionable: true, + reactionGroupCount: 10, + canReact: true, + addReactionButtonActive: false, + ...overrides, + }); + + it("computes initial snapshot from props", () => { + const vm = createVm(); + const snapshot = vm.getSnapshot(); + + expect(snapshot.isVisible).toBe(true); + expect(snapshot.showAllButtonVisible).toBe(true); + expect(snapshot.showAddReactionButton).toBe(true); + expect(snapshot.addReactionButtonActive).toBe(false); + expect(snapshot.className).toContain("mx_ReactionsRow"); + }); + + it("hides show-all after onShowAllClick", () => { + const vm = createVm(); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.onShowAllClick(); + + expect(vm.getSnapshot().showAllButtonVisible).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("updates visibility when reaction group count changes", () => { + const vm = createVm(); + + vm.setReactionGroupCount(0); + + expect(vm.getSnapshot().isVisible).toBe(false); + }); + + it("updates add-reaction button visibility from canReact", () => { + const vm = createVm(); + + vm.setCanReact(false); + + expect(vm.getSnapshot().showAddReactionButton).toBe(false); + }); + + it("updates add-reaction active state", () => { + const vm = createVm(); + + vm.setAddReactionButtonActive(true); + + expect(vm.getSnapshot().addReactionButtonActive).toBe(true); + }); + + it("forwards add-reaction handlers", () => { + const vm = createVm(); + const onAddReactionClick = jest.fn(); + const onAddReactionContextMenu = jest.fn(); + + vm.setAddReactionHandlers({ + onAddReactionClick, + onAddReactionContextMenu, + }); + + const clickEvent = { + currentTarget: document.createElement("button"), + } as unknown as MouseEvent; + vm.onAddReactionClick(clickEvent); + vm.onAddReactionContextMenu(clickEvent); + + expect(onAddReactionClick).toHaveBeenCalledWith(clickEvent); + expect(onAddReactionContextMenu).toHaveBeenCalledWith(clickEvent); + }); + + it("emits only for setters that always merge when values are unchanged", () => { + const vm = createVm(); + const previousSnapshot = vm.getSnapshot(); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setCanReact(true); + vm.setReactionGroupCount(10); + vm.setActionable(true); + vm.setAddReactionButtonActive(false); + + // `setReactionGroupCount` is optimized and skips emit for unchanged derived values. + // The other setters always merge and therefore emit. + expect(listener).toHaveBeenCalledTimes(3); + expect(vm.getSnapshot()).toEqual(previousSnapshot); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..5588f34e92f56fcf21ea073943575602a48eaa13 GIT binary patch literal 7506 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 Ef0pncbpQYW literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..5ce5943eef688aa499fe897a87d81364d9ad3497 GIT binary patch literal 7533 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: JSX.Element => { + const tooltipVm = useMockedViewModel( + { + formattedSenders: "Alice and Bob", + caption: undefined, + tooltipOpen: false, + }, + {}, + ); + + const vm = useMockedViewModel( + { + content, + count, + "isSelected": !!isSelected, + "isDisabled": false, + tooltipVm, + "aria-label": `${count} reactions for ${content}`, + }, + { + onClick: fn(), + }, + ); + + return ; +}; + +const DefaultReactionButtons = (): JSX.Element => ( + <> + + + + +); + +type WrapperProps = ReactionsRowViewSnapshot & Partial; + +const ReactionsRowViewWrapper = ({ + onShowAllClick, + onAddReactionClick, + onAddReactionContextMenu, + ...snapshotProps +}: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, { + onShowAllClick: onShowAllClick ?? fn(), + onAddReactionClick: onAddReactionClick ?? fn(), + onAddReactionContextMenu: onAddReactionContextMenu ?? fn(), + }); + + return ; +}; + +const meta = { + title: "MessageBody/ReactionsRow", + component: ReactionsRowViewWrapper, + tags: ["autodocs"], + args: { + ariaLabel: "Reactions", + isVisible: true, + children: , + showAllButtonVisible: false, + showAllButtonLabel: "Show all", + showAddReactionButton: true, + addReactionButtonLabel: "Add reaction", + addReactionButtonVisible: true, + addReactionButtonActive: false, + addReactionButtonDisabled: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithShowAllButton: Story = { + args: { + showAllButtonVisible: true, + }, +}; + +export const AddReactionButtonActive: Story = { + args: { + addReactionButtonActive: true, + }, +}; + +export const AddReactionButtonHiddenUntilHover: Story = { + args: { + addReactionButtonVisible: false, + }, +}; + +export const Hidden: Story = { + args: { + isVisible: false, + }, +}; diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx new file mode 100644 index 0000000000..a6f28b9370 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import React, { type MouseEventHandler } from "react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../viewmodel"; +import { + ReactionsRowView, + type ReactionsRowViewActions, + type ReactionsRowViewModel, + type ReactionsRowViewSnapshot, +} from "./ReactionsRowView"; +import * as stories from "./ReactionsRow.stories"; + +const { Default, WithShowAllButton, Hidden } = composeStories(stories); + +describe("ReactionsRowView", () => { + it("renders the default reactions row", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the row with a show-all button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("does not render the row when hidden", () => { + render(); + expect(screen.queryByRole("toolbar")).not.toBeInTheDocument(); + }); + + it("invokes show-all and add-reaction actions", async () => { + const user = userEvent.setup(); + + const onShowAllClick = vi.fn(); + const onAddReactionClick = vi.fn(); + const onAddReactionContextMenu = vi.fn(); + + class TestReactionsRowViewModel + extends MockViewModel + implements ReactionsRowViewActions + { + public onShowAllClick?: () => void; + public onAddReactionClick?: MouseEventHandler; + public onAddReactionContextMenu?: MouseEventHandler; + + public constructor(snapshot: ReactionsRowViewSnapshot, actions: ReactionsRowViewActions) { + super(snapshot); + Object.assign(this, actions); + } + } + + const vm = new TestReactionsRowViewModel( + { + ariaLabel: "Reactions", + isVisible: true, + showAllButtonVisible: true, + showAllButtonLabel: "Show all", + showAddReactionButton: true, + addReactionButtonLabel: "Add reaction", + addReactionButtonVisible: true, + children: 👍, + }, + { + onShowAllClick, + onAddReactionClick, + onAddReactionContextMenu, + }, + ) as ReactionsRowViewModel; + + render(); + + await user.click(screen.getByRole("button", { name: "Show all" })); + await user.click(screen.getByRole("button", { name: "Add reaction" })); + await user.pointer({ target: screen.getByRole("button", { name: "Add reaction" }), keys: "[MouseRight]" }); + + expect(onShowAllClick).toHaveBeenCalledTimes(1); + expect(onAddReactionClick).toHaveBeenCalledTimes(1); + expect(onAddReactionContextMenu).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx b/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx new file mode 100644 index 0000000000..fffd9d6c1b --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, type MouseEventHandler, type ReactNode } from "react"; +import classNames from "classnames"; +import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Tooltip } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; +import styles from "./ReactionsRow.module.css"; + +export interface ReactionsRowViewSnapshot { + /** + * Toolbar label announced by assistive technologies. + */ + ariaLabel: string; + /** + * Controls whether the row should render at all. + */ + isVisible: boolean; + /** + * Reaction button elements to render in the row. + */ + children?: ReactNode; + /** + * Optional CSS className for the row container. + */ + className?: string; + /** + * Whether to render the "show all" button. + */ + showAllButtonVisible?: boolean; + /** + * Label shown for the "show all" button. + */ + showAllButtonLabel?: string; + /** + * Whether to render the add-reaction button. + */ + showAddReactionButton?: boolean; + /** + * Accessible label for the add-reaction button. + */ + addReactionButtonLabel: string; + /** + * Force the add-reaction button to be visible. + */ + addReactionButtonVisible?: boolean; + /** + * Marks the add-reaction button as active. + */ + addReactionButtonActive?: boolean; + /** + * Disables the add-reaction button. + */ + addReactionButtonDisabled?: boolean; +} + +export interface ReactionsRowViewActions { + /** + * Invoked when the user clicks the "show all" button. + */ + onShowAllClick?: () => void; + /** + * Invoked when the user clicks the add-reaction button. + */ + onAddReactionClick?: MouseEventHandler; + /** + * Invoked on right-click/context-menu for the add-reaction button. + */ + onAddReactionContextMenu?: MouseEventHandler; +} + +export type ReactionsRowViewModel = ViewModel; + +interface ReactionsRowViewProps { + vm: ReactionsRowViewModel; +} + +export function ReactionsRowView({ vm }: Readonly): JSX.Element { + const { + ariaLabel, + isVisible, + children, + className, + showAllButtonVisible, + showAllButtonLabel, + showAddReactionButton, + addReactionButtonLabel, + addReactionButtonVisible, + addReactionButtonActive, + addReactionButtonDisabled, + } = useViewModel(vm); + + if (!isVisible) { + return <>; + } + + const addReactionButtonClasses = classNames(styles.addReactionButton, { + [styles.addReactionButtonVisible]: addReactionButtonVisible, + [styles.addReactionButtonActive]: addReactionButtonActive, + [styles.addReactionButtonDisabled]: addReactionButtonDisabled, + }); + + const onAddReactionContextMenu: MouseEventHandler | undefined = vm.onAddReactionContextMenu + ? (event): void => { + event.preventDefault(); + vm.onAddReactionContextMenu?.(event); + } + : undefined; + + const addReactionButton = ( + + ); + + return ( +
    + {children} + {showAllButtonVisible && ( + + )} + {showAddReactionButton && ( + + {addReactionButton} + + )} +
    + ); +} diff --git a/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap b/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap new file mode 100644 index 0000000000..404aa6dd05 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap @@ -0,0 +1,183 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReactionsRowView > renders the default reactions row 1`] = ` +
    + +
    +`; + +exports[`ReactionsRowView > renders the row with a show-all button 1`] = ` +
    + +
    +`; diff --git a/packages/shared-components/src/message-body/ReactionRow/index.tsx b/packages/shared-components/src/message-body/ReactionRow/index.tsx new file mode 100644 index 0000000000..4925bf02a0 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/index.tsx @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + ReactionsRowView, + type ReactionsRowViewSnapshot, + type ReactionsRowViewModel, + type ReactionsRowViewActions, +} from "./ReactionsRowView"; diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css index 3d1af1a83d..ab4360759b 100644 --- a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css @@ -8,6 +8,7 @@ .reactionsRowButton { display: inline-flex; all: unset; + cursor: pointer; line-height: var(--cpd-font-size-heading-sm); padding: 1px var(--cpd-space-1-5x); border: 1px solid var(--cpd-color-gray-400);