From e26cbba541363ecdb4e7d3fa4973a9ca85439677 Mon Sep 17 00:00:00 2001 From: Zack Date: Wed, 25 Feb 2026 12:18:03 +0100 Subject: [PATCH] Refactor Reactions Row Button to shared-components (#31993) * Refactoring of ReactionRowButton to shared component MVVM * Removal of old component and creation of unit tests * Update * Update tests * Update tests to mimic VM * Update Lint Spacing * Added onKeyDown to follow wcag rules * Remove Unused code * Update screenshots * Removal of unessecery test and story * Update snapshot * Refactor reactions row VMs to granular setters and merge cheap snapshot updates * Elist Fix * Revert ReactionRowButtonToolTip Test * Fix ReactionsRowButtonViewModel tooltip sync to use tooltip setProps * Add dedicated ReactionsRowButtonViewModel unit tests for setters, tooltip sync, and click actions * Better Wording On Functions * Update snapshot * Update packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx Co-authored-by: Florian Duros * use native button and tighten view model * Update Snapshots + small fixes on reactionrow * Removal of Null on viewmodel and adapting ReactionRow * Update test and removal of unused test since me MVVMD ReactionRowButton * align assertions with refactored update behavior * FIx issue with classNames component * Update snapshot * Removal of old test snapshot * Update Snapshot * Implement Css + Snapshot Updates * Update Snapshot and css to match old component style * restore MatrixClientContext fallback in ReactionsRow for export/test rendering * restore client fallback in ReactionsRow to preserve export rendering * Remove Unused Pcss FIle * Update Css * Update misstake always having button default to disabled render * Remove unsimiler css to original component * Update Snapshot to reflect css adjustments * Update css * Update font to compund * Update css to reflect old component * Update css to compund * Update Snapshot and css * Update css * Update HTML snapshot * Update css * Update Css * Update snapshots * Update HTML snapshot * Update css + snapshot * Update HTML snapshot * Removal of mx css * Update snapshot based on css removal * Update Html snapshot * Apply suggestion from @florianduros Co-authored-by: Florian Duros * remove setContext from ReactionsRowButtonViewModel * Update packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx Co-authored-by: Florian Duros * add tooltipVm to ReactionsRowButtonViewSnapshot * added compound token variables * remove className from content and count inner elements * use useMatrixClientContext() directly for ReactionsRowButtonViewModel * Update snapshots * Update snapshot + fix Typescript error on test file * Removal of line-height in css * Added line-height back and removed font: inherit; * derive ReactionsRowButton className/ariaLabel types from HTML button attrs * Update packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx Co-authored-by: Florian Duros * Update src/viewmodels/message-body/ReactionsRowButtonViewModel.ts Co-authored-by: Florian Duros * Update src/viewmodels/message-body/ReactionsRowButtonViewModel.ts Co-authored-by: Florian Duros * Update test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx Co-authored-by: Florian Duros * Update snapshots and lint issues * Update model to respond to changes * Update aria label on view --------- Co-authored-by: Florian Duros --- apps/web/res/css/_components.pcss | 1 - .../views/messages/_ReactionsRowButton.pcss | 34 -- .../views/messages/ReactionsRow.tsx | 54 +- .../views/messages/ReactionsRowButton.tsx | 164 ------ .../ReactionsRowButtonViewModel.ts | 211 +++++++ .../messages/ReactionsRowButton-test.tsx | 546 ------------------ .../ReactionsRowButton-test.tsx.snap | 120 ---- .../__snapshots__/HTMLExport-test.ts.snap | 2 +- .../ReactionsRowButtonViewModel-test.tsx | 171 ++++++ .../default-auto.png | Bin 0 -> 18065 bytes .../selected-auto.png | Bin 0 -> 18216 bytes .../with-tooltip-auto.png | Bin 0 -> 21266 bytes packages/shared-components/src/index.ts | 1 + .../ReactionsRowButton.module.css | 39 ++ .../ReactionsRowButton.stories.tsx | 93 +++ .../ReactionsRowButton.test.tsx | 27 + .../ReactionsRowButtonView.tsx | 104 ++++ .../ReactionsRowButton.test.tsx.snap | 49 ++ .../message-body/ReactionsRowButton/index.tsx | 13 + 19 files changed, 760 insertions(+), 869 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_ReactionsRowButton.pcss delete mode 100644 apps/web/src/components/views/messages/ReactionsRowButton.tsx create mode 100644 apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts delete mode 100644 apps/web/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap create mode 100644 apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/selected-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/with-tooltip-auto.png create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.test.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/__snapshots__/ReactionsRowButton.test.tsx.snap create mode 100644 packages/shared-components/src/message-body/ReactionsRowButton/index.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 32757abf20..c8ca64d33b 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -241,7 +241,6 @@ @import "./views/messages/_MjolnirBody.pcss"; @import "./views/messages/_PinnedMessageBadge.pcss"; @import "./views/messages/_ReactionsRow.pcss"; -@import "./views/messages/_ReactionsRowButton.pcss"; @import "./views/messages/_RedactedBody.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; diff --git a/apps/web/res/css/views/messages/_ReactionsRowButton.pcss b/apps/web/res/css/views/messages/_ReactionsRowButton.pcss deleted file mode 100644 index 917bcfbb84..0000000000 --- a/apps/web/res/css/views/messages/_ReactionsRowButton.pcss +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2019-2024 New Vector 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. -*/ - -.mx_ReactionsRowButton { - display: inline-flex; - line-height: $font-20px; - padding: 1px 6px; - border: 1px solid var(--cpd-color-gray-400); - border-radius: 10px; - background-color: var(--cpd-color-gray-200); - user-select: none; - align-items: center; - - &.mx_ReactionsRowButton_selected { - background-color: $accent-300; - border-color: $accent-800; - } - - &.mx_AccessibleButton_disabled { - cursor: not-allowed; - } - - .mx_ReactionsRowButton_content { - max-width: 100px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - padding-right: 4px; - } -} diff --git a/apps/web/src/components/views/messages/ReactionsRow.tsx b/apps/web/src/components/views/messages/ReactionsRow.tsx index 4ac68ede3e..ef21ad9e82 100644 --- a/apps/web/src/components/views/messages/ReactionsRow.tsx +++ b/apps/web/src/components/views/messages/ReactionsRow.tsx @@ -6,22 +6,24 @@ 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, { type JSX, type SyntheticEvent } from "react"; +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 ReactionsRowButton from "./ReactionsRowButton"; 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; @@ -64,6 +66,52 @@ const ReactButton: React.FC = ({ mxEvent, reactions }) => { ); }; +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; @@ -186,7 +234,7 @@ export default class ReactionsRow extends React.PureComponent { return mxEvent.getRelation()?.key === content; }); return ( - { - public static contextType = MatrixClientContext; - declare public context: React.ContextType; - - private reactionsRowButtonTooltipViewModel: ReactionsRowButtonTooltipViewModel; - - public constructor(props: IProps, context: React.ContextType) { - super(props, context); - this.reactionsRowButtonTooltipViewModel = new ReactionsRowButtonTooltipViewModel({ - client: context, - mxEvent: props.mxEvent, - content: props.content, - reactionEvents: props.reactionEvents, - customReactionImagesEnabled: props.customReactionImagesEnabled, - }); - } - - public componentDidUpdate(prevProps: IProps): void { - if ( - prevProps.mxEvent !== this.props.mxEvent || - prevProps.content !== this.props.content || - prevProps.reactionEvents !== this.props.reactionEvents || - prevProps.customReactionImagesEnabled !== this.props.customReactionImagesEnabled - ) { - // View model bails out if derived snapshot hasn't changed. - this.reactionsRowButtonTooltipViewModel.setProps({ - client: this.context, - mxEvent: this.props.mxEvent, - content: this.props.content, - reactionEvents: this.props.reactionEvents, - customReactionImagesEnabled: this.props.customReactionImagesEnabled, - }); - } - } - - public componentWillUnmount(): void { - this.reactionsRowButtonTooltipViewModel.dispose(); - } - - public onClick = (): void => { - const { mxEvent, myReactionEvent, content } = this.props; - if (myReactionEvent) { - this.context.redactEvent(mxEvent.getRoomId()!, myReactionEvent.getId()!); - } else { - this.context.sendEvent(mxEvent.getRoomId()!, EventType.Reaction, { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: mxEvent.getId()!, - key: content, - }, - }); - dis.dispatch({ action: "message_sent" }); - } - }; - - public render(): React.ReactNode { - const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props; - - const classes = classNames({ - mx_ReactionsRowButton: true, - mx_ReactionsRowButton_selected: !!myReactionEvent, - }); - - const room = this.context.getRoom(mxEvent.getRoomId()); - let label: string | undefined; - let customReactionName: string | undefined; - if (room) { - const senders: string[] = []; - for (const reactionEvent of reactionEvents) { - const member = room.getMember(reactionEvent.getSender()!); - senders.push(member?.name || reactionEvent.getSender()!); - customReactionName = - (this.props.customReactionImagesEnabled && - REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) || - undefined; - } - - const reactors = formatList(senders, 6); - if (content) { - label = _t("timeline|reactions|label", { - reactors, - content: customReactionName || content, - }); - } else { - label = reactors; - } - } - - let reactionContent = ( - - ); - if (this.props.customReactionImagesEnabled && content.startsWith("mxc://")) { - const imageSrc = mediaFromMxc(content).srcHttp; - if (imageSrc) { - reactionContent = ( - {customReactionName - ); - } - } - - return ( - - - {reactionContent} - - - - ); - } -} diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts new file mode 100644 index 0000000000..6435a38d6b --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts @@ -0,0 +1,211 @@ +/* + * 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 { EventType, type MatrixClient, type MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type ReactionsRowButtonViewSnapshot, + type ReactionsRowButtonViewModel as ReactionsRowButtonViewModelInterface, +} from "@element-hq/web-shared-components"; + +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"; + +export interface ReactionsRowButtonViewModelProps { + /** + * The Matrix client instance. + */ + client: MatrixClient; + /** + * The event we're displaying reactions for. + */ + mxEvent: MatrixEvent; + /** + * The reaction content / key / emoji. + */ + content: string; + /** + * The count of votes for this key. + */ + count: number; + /** + * The CSS class name. + */ + className?: string; + /** + * A list of Matrix reaction events for this key. + */ + reactionEvents: MatrixEvent[]; + /** + * A possible Matrix event if the current user has voted for this type. + */ + myReactionEvent?: MatrixEvent; + /** + * Whether to prevent quick-reactions by clicking on this reaction. + */ + disabled?: boolean; + /** + * Whether to render custom image reactions. + */ + customReactionImagesEnabled?: boolean; +} + +export class ReactionsRowButtonViewModel + extends BaseViewModel + implements ReactionsRowButtonViewModelInterface +{ + private readonly tooltipVm: ReactionsRowButtonTooltipViewModel; + private static readonly getAriaLabel = (snapshot: ReactionsRowButtonViewSnapshot): string | undefined => + (snapshot as ReactionsRowButtonViewSnapshot & { ariaLabel?: string }).ariaLabel; + + private static readonly computeSnapshot = ( + props: ReactionsRowButtonViewModelProps, + tooltipVm: ReactionsRowButtonTooltipViewModel, + ): ReactionsRowButtonViewSnapshot => { + const { + client, + mxEvent, + content, + count, + className, + reactionEvents, + myReactionEvent, + disabled, + customReactionImagesEnabled, + } = props; + + const room = client.getRoom(mxEvent.getRoomId()); + let ariaLabel: string | undefined; + let customReactionName: string | undefined; + + if (room) { + const senders: string[] = []; + for (const reactionEvent of reactionEvents) { + const member = room.getMember(reactionEvent.getSender()!); + senders.push(member?.name || reactionEvent.getSender()!); + customReactionName = + (customReactionImagesEnabled && REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) || + undefined; + } + + const reactors = formatList(senders, 6); + if (content) { + ariaLabel = _t("timeline|reactions|label", { + reactors, + content: customReactionName || content, + }); + } else { + ariaLabel = reactors; + } + } + + let imageSrc: string | undefined; + let imageAlt: string | undefined; + if (customReactionImagesEnabled && content.startsWith("mxc://")) { + const resolved = mediaFromMxc(content).srcHttp; + if (resolved) { + imageSrc = resolved; + imageAlt = customReactionName || _t("timeline|reactions|custom_reaction_fallback_label"); + } + } + + const snapshot = { + content, + count, + className, + ariaLabel, + isSelected: !!myReactionEvent, + isDisabled: !!disabled, + imageSrc, + imageAlt, + tooltipVm, + }; + + return snapshot; + }; + + public constructor(props: ReactionsRowButtonViewModelProps) { + const tooltipVm = new ReactionsRowButtonTooltipViewModel({ + client: props.client, + mxEvent: props.mxEvent, + content: props.content, + reactionEvents: props.reactionEvents, + customReactionImagesEnabled: props.customReactionImagesEnabled, + }); + super(props, ReactionsRowButtonViewModel.computeSnapshot(props, tooltipVm)); + this.tooltipVm = tooltipVm; + this.disposables.track(tooltipVm); + } + + private setSnapshot(nextSnapshot: ReactionsRowButtonViewSnapshot): void { + const currentSnapshot = this.snapshot.current; + + if ( + nextSnapshot.content === currentSnapshot.content && + nextSnapshot.count === currentSnapshot.count && + ReactionsRowButtonViewModel.getAriaLabel(nextSnapshot) === + ReactionsRowButtonViewModel.getAriaLabel(currentSnapshot) && + nextSnapshot.isSelected === currentSnapshot.isSelected && + nextSnapshot.isDisabled === currentSnapshot.isDisabled && + nextSnapshot.imageSrc === currentSnapshot.imageSrc && + nextSnapshot.imageAlt === currentSnapshot.imageAlt + ) { + return; + } + + this.snapshot.set(nextSnapshot); + } + + public setReactionData( + content: string, + reactionEvents: MatrixEvent[], + customReactionImagesEnabled?: boolean, + ): void { + this.props = { ...this.props, content, reactionEvents, customReactionImagesEnabled }; + + this.tooltipVm.setProps({ content, reactionEvents, customReactionImagesEnabled }); + this.setSnapshot(ReactionsRowButtonViewModel.computeSnapshot(this.props, this.tooltipVm)); + } + + public setCount(count: number): void { + this.props = { ...this.props, count }; + this.snapshot.merge({ count }); + } + + public setMyReactionEvent(myReactionEvent?: MatrixEvent): void { + this.props = { ...this.props, myReactionEvent }; + this.snapshot.merge({ isSelected: !!myReactionEvent }); + } + + public setDisabled(disabled?: boolean): void { + this.props = { ...this.props, disabled }; + this.snapshot.merge({ isDisabled: !!disabled }); + } + + public onClick = (): void => { + const { client, mxEvent, myReactionEvent, content, disabled } = this.props; + if (disabled) return; + + if (myReactionEvent) { + client.redactEvent(mxEvent.getRoomId()!, myReactionEvent.getId()!); + return; + } + + client.sendEvent(mxEvent.getRoomId()!, EventType.Reaction, { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: mxEvent.getId()!, + key: content, + }, + }); + dis.dispatch({ action: "message_sent" }); + }; +} diff --git a/apps/web/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx b/apps/web/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx deleted file mode 100644 index ef6fa3ba61..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx +++ /dev/null @@ -1,546 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 Beeper - -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 from "react"; -import { EventType, type IContent, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; -import { fireEvent, render } from "jest-matrix-react"; - -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { getMockClientWithEventEmitter } from "../../../../test-utils"; -import ReactionsRowButton, { type IProps } from "../../../../../src/components/views/messages/ReactionsRowButton"; -import dis from "../../../../../src/dispatcher/dispatcher"; -import { type Media, mediaFromMxc } from "../../../../../src/customisations/Media"; - -jest.mock("../../../../../src/dispatcher/dispatcher"); - -jest.mock("../../../../../src/customisations/Media", () => ({ - mediaFromMxc: jest.fn(), -})); - -jest.mock("@element-hq/web-shared-components", () => { - const actual = jest.requireActual("@element-hq/web-shared-components"); - return { - ...actual, - ReactionsRowButtonTooltipView: ({ children }: { children: React.ReactNode }) => <>{children}, - }; -}); - -const mockMediaFromMxc = mediaFromMxc as jest.MockedFunction; - -describe("ReactionsRowButton", () => { - const userId = "@alice:server"; - const roomId = "!randomcharacters:aser.ver"; - const mockClient = getMockClientWithEventEmitter({ - getRoom: jest.fn(), - sendEvent: jest.fn().mockResolvedValue({ event_id: "$sent_event" }), - redactEvent: jest.fn().mockResolvedValue({}), - }); - const room = new Room(roomId, mockClient, userId); - - const createProps = (relationContent: IContent): IProps => ({ - mxEvent: new MatrixEvent({ - room_id: roomId, - event_id: "$test:example.com", - content: { body: "test" }, - }), - content: relationContent["m.relates_to"]?.key || "", - count: 2, - reactionEvents: [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user1:example.com", - content: relationContent, - }), - new MatrixEvent({ - type: "m.reaction", - sender: "@user2:example.com", - content: relationContent, - }), - ], - customReactionImagesEnabled: true, - }); - - beforeEach(function () { - jest.clearAllMocks(); - mockClient.credentials = { userId: userId }; - mockClient.getRoom.mockImplementation((roomId: string): Room | null => { - return roomId === room.roomId ? room : null; - }); - // Default mock for mediaFromMxc - mockMediaFromMxc.mockReturnValue({ - srcHttp: "https://not.a.real.url", - } as unknown as Media); - }); - - it("renders reaction row button emojis correctly", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user2:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - const root = render( - - - , - ); - expect(root.asFragment()).toMatchSnapshot(); - - // Try hover and make sure that the ReactionsRowButtonTooltip works - const reactionButton = root.getByRole("button"); - const event = new MouseEvent("mouseover", { - bubbles: true, - cancelable: true, - }); - reactionButton.dispatchEvent(event); - - expect(root.asFragment()).toMatchSnapshot(); - }); - - it("renders reaction row button custom image reactions correctly", () => { - const props = createProps({ - "com.beeper.reaction.shortcode": ":test:", - "shortcode": ":test:", - "m.relates_to": { - event_id: "$user1:example.com", - key: "mxc://example.com/123456789", - rel_type: "m.annotation", - }, - }); - - const root = render( - - - , - ); - expect(root.asFragment()).toMatchSnapshot(); - - // Try hover and make sure that the ReactionsRowButtonTooltip works - const reactionButton = root.getByRole("button"); - const event = new MouseEvent("mouseover", { - bubbles: true, - cancelable: true, - }); - reactionButton.dispatchEvent(event); - - expect(root.asFragment()).toMatchSnapshot(); - }); - - it("renders without a room", () => { - mockClient.getRoom.mockImplementation(() => null); - - const props = createProps({}); - - const root = render( - - - , - ); - - expect(root.asFragment()).toMatchSnapshot(); - }); - - it("calls setProps on ViewModel when props change", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender, container } = render( - - - , - ); - - // Create new props with different values - const newMxEvent = new MatrixEvent({ - room_id: roomId, - event_id: "$test2:example.com", - content: { body: "test2" }, - }); - - const newReactionEvents = [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user3:example.com", - content: { - "m.relates_to": { - event_id: "$user3:example.com", - key: "👎", - rel_type: "m.annotation", - }, - }, - }), - ]; - - const updatedProps: IProps = { - ...props, - mxEvent: newMxEvent, - content: "👎", - reactionEvents: newReactionEvents, - customReactionImagesEnabled: false, - }; - - rerender( - - - , - ); - - // The component should have updated - verify by checking the rendered content - expect(container.querySelector(".mx_ReactionsRowButton_content")?.textContent).toBe("👎"); - }); - - it("disposes ViewModel on unmount", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { unmount } = render( - - - , - ); - - // Unmount should not throw - expect(() => unmount()).not.toThrow(); - }); - - it("redacts reaction when clicking with myReactionEvent", () => { - const myReactionEvent = new MatrixEvent({ - type: "m.reaction", - sender: userId, - event_id: "$my_reaction:example.com", - content: { - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }, - }); - - const props: IProps = { - ...createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }), - myReactionEvent, - }; - - const root = render( - - - , - ); - - const button = root.getByRole("button"); - fireEvent.click(button); - - expect(mockClient.redactEvent).toHaveBeenCalledWith(roomId, "$my_reaction:example.com"); - }); - - it("sends reaction when clicking without myReactionEvent", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$test:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const root = render( - - - , - ); - - const button = root.getByRole("button"); - fireEvent.click(button); - - expect(mockClient.sendEvent).toHaveBeenCalledWith(roomId, EventType.Reaction, { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: "$test:example.com", - key: "👍", - }, - }); - expect(dis.dispatch).toHaveBeenCalledWith({ action: "message_sent" }); - }); - - it("uses reactors as label when content is empty", () => { - const props: IProps = { - mxEvent: new MatrixEvent({ - room_id: roomId, - event_id: "$test:example.com", - content: { body: "test" }, - }), - content: "", // Empty content - count: 2, - reactionEvents: [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user1:example.com", - content: {}, - }), - new MatrixEvent({ - type: "m.reaction", - sender: "@user2:example.com", - content: {}, - }), - ], - customReactionImagesEnabled: true, - }; - - const root = render( - - - , - ); - - // The button should still render - const button = root.getByRole("button"); - expect(button).toBeInTheDocument(); - }); - - it("renders custom image reaction with fallback label when no shortcode", () => { - const props: IProps = { - mxEvent: new MatrixEvent({ - room_id: roomId, - event_id: "$test:example.com", - content: { body: "test" }, - }), - content: "mxc://example.com/custom_image", - count: 1, - reactionEvents: [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user1:example.com", - content: { - "m.relates_to": { - event_id: "$test:example.com", - key: "mxc://example.com/custom_image", - rel_type: "m.annotation", - }, - }, - }), - ], - customReactionImagesEnabled: true, - }; - - const root = render( - - - , - ); - - // Should render an image element for custom reaction - const img = root.container.querySelector("img.mx_ReactionsRowButton_content"); - expect(img).toBeInTheDocument(); - expect(img).toHaveAttribute("src", "https://not.a.real.url"); - }); - - it("falls back to text when mxc URL cannot be converted to HTTP", () => { - // Make mediaFromMxc return null srcHttp to simulate failed conversion - mockMediaFromMxc.mockReturnValueOnce({ - srcHttp: null, - } as unknown as Media); - - const props: IProps = { - mxEvent: new MatrixEvent({ - room_id: roomId, - event_id: "$test:example.com", - content: { body: "test" }, - }), - content: "mxc://example.com/invalid_image", - count: 1, - reactionEvents: [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user1:example.com", - content: { - "m.relates_to": { - event_id: "$test:example.com", - key: "mxc://example.com/invalid_image", - rel_type: "m.annotation", - }, - }, - }), - ], - customReactionImagesEnabled: true, - }; - - const root = render( - - - , - ); - - // Should render span (not img) when imageSrc is null - const span = root.container.querySelector("span.mx_ReactionsRowButton_content"); - expect(span).toBeInTheDocument(); - const img = root.container.querySelector("img.mx_ReactionsRowButton_content"); - expect(img).not.toBeInTheDocument(); - }); - - it("updates ViewModel when only mxEvent changes", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender } = render( - - - , - ); - - // Only change mxEvent - const newMxEvent = new MatrixEvent({ - room_id: roomId, - event_id: "$test2:example.com", - content: { body: "test2" }, - }); - - expect(() => - rerender( - - - , - ), - ).not.toThrow(); - }); - - it("updates ViewModel when only content changes", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender, container } = render( - - - , - ); - - // Only change content - rerender( - - - , - ); - - expect(container.querySelector(".mx_ReactionsRowButton_content")?.textContent).toBe("👎"); - }); - - it("updates ViewModel when only reactionEvents changes", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender } = render( - - - , - ); - - // Only change reactionEvents - const newReactionEvents = [ - new MatrixEvent({ - type: "m.reaction", - sender: "@user3:example.com", - content: { - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }, - }), - ]; - - expect(() => - rerender( - - - , - ), - ).not.toThrow(); - }); - - it("updates ViewModel when only customReactionImagesEnabled changes", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender } = render( - - - , - ); - - // Only change customReactionImagesEnabled - expect(() => - rerender( - - - , - ), - ).not.toThrow(); - }); - - it("does not update ViewModel when props stay the same", () => { - const props = createProps({ - "m.relates_to": { - event_id: "$user1:example.com", - key: "👍", - rel_type: "m.annotation", - }, - }); - - const { rerender } = render( - - - , - ); - - // Rerender with same props - setProps should not be called - expect(() => - rerender( - - - , - ), - ).not.toThrow(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap deleted file mode 100644 index db868189dd..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/ReactionsRowButton-test.tsx.snap +++ /dev/null @@ -1,120 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 1`] = ` - -
- :test: - -
-
-`; - -exports[`ReactionsRowButton renders reaction row button custom image reactions correctly 2`] = ` - -
- :test: - -
-
-`; - -exports[`ReactionsRowButton renders reaction row button emojis correctly 1`] = ` - -
- - -
-
-`; - -exports[`ReactionsRowButton renders reaction row button emojis correctly 2`] = ` - -
- - -
-
-`; - -exports[`ReactionsRowButton renders without a room 1`] = ` - -
-
-
-`; 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 9f2a76734a..84379afbcc 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/ReactionsRowButtonViewModel-test.tsx b/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx new file mode 100644 index 0000000000..27aa901653 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx @@ -0,0 +1,171 @@ +/* + * 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 { EventType, type MatrixClient, type MatrixEvent, RelationType, type Room } from "matrix-js-sdk/src/matrix"; + +import { + ReactionsRowButtonViewModel, + type ReactionsRowButtonViewModelProps, +} from "../../../src/viewmodels/message-body/ReactionsRowButtonViewModel"; +import { type ReactionsRowButtonTooltipViewModel } from "../../../src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel"; +import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils"; +import dis from "../../../src/dispatcher/dispatcher"; + +jest.mock("../../../src/dispatcher/dispatcher"); + +describe("ReactionsRowButtonViewModel", () => { + let client: MatrixClient; + let room: Room; + let mxEvent: MatrixEvent; + + const createReactionEvent = (senderId: string, key = "👍"): MatrixEvent => { + return mkEvent({ + event: true, + type: "m.reaction", + room: room.roomId, + user: senderId, + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: mxEvent.getId(), + key, + }, + }, + }); + }; + + const createProps = (overrides?: Partial): ReactionsRowButtonViewModelProps => ({ + client, + mxEvent, + content: "👍", + count: 2, + reactionEvents: [createReactionEvent("@alice:example.org"), createReactionEvent("@bob:example.org")], + disabled: false, + customReactionImagesEnabled: false, + ...overrides, + }); + + const getTooltipVm = (vm: ReactionsRowButtonViewModel): ReactionsRowButtonTooltipViewModel => + vm.getSnapshot().tooltipVm as ReactionsRowButtonTooltipViewModel; + const getAriaLabel = (vm: ReactionsRowButtonViewModel): string | undefined => + (vm.getSnapshot() as { ariaLabel?: string }).ariaLabel; + + beforeEach(() => { + jest.clearAllMocks(); + client = createTestClient(); + room = mkStubRoom("!room:example.org", "Test Room", client); + jest.spyOn(client, "getRoom").mockReturnValue(room); + mxEvent = mkEvent({ + event: true, + type: "m.room.message", + room: room.roomId, + user: "@sender:example.org", + content: { body: "Test message", msgtype: "m.text" }, + }); + }); + + it("updates count with merge and does not touch tooltip props", () => { + const vm = new ReactionsRowButtonViewModel(createProps()); + const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps"); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setCount(5); + + expect(vm.getSnapshot().count).toBe(5); + expect(listener).toHaveBeenCalledTimes(1); + expect(tooltipSetPropsSpy).not.toHaveBeenCalled(); + + vm.setCount(5); + + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("includes an ariaLabel in the snapshot", () => { + const vm = new ReactionsRowButtonViewModel(createProps()); + + expect(getAriaLabel(vm)).toContain("reacted with 👍"); + }); + + it("updates selected state with myReactionEvent without touching tooltip props", () => { + const vm = new ReactionsRowButtonViewModel(createProps()); + const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps"); + const listener = jest.fn(); + vm.subscribe(listener); + const myReactionEvent = createReactionEvent("@me:example.org"); + + vm.setMyReactionEvent(myReactionEvent); + + expect(vm.getSnapshot().isSelected).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(tooltipSetPropsSpy).not.toHaveBeenCalled(); + }); + + it("updates disabled state without touching tooltip props", () => { + const vm = new ReactionsRowButtonViewModel(createProps({ disabled: false })); + const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps"); + + vm.setDisabled(true); + + expect(vm.getSnapshot().isDisabled).toBe(true); + expect(tooltipSetPropsSpy).not.toHaveBeenCalled(); + }); + + it("setReactionData forwards to tooltip via setProps and updates snapshot content", () => { + const vm = new ReactionsRowButtonViewModel(createProps()); + const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps"); + const reactionEvents = [createReactionEvent("@carol:example.org", "👎")]; + + vm.setReactionData("👎", reactionEvents, false); + + expect(vm.getSnapshot().content).toBe("👎"); + expect(tooltipSetPropsSpy).toHaveBeenCalledWith({ + content: "👎", + reactionEvents, + customReactionImagesEnabled: false, + }); + + vm.setReactionData("👎", reactionEvents, false); + + expect(tooltipSetPropsSpy).toHaveBeenCalledTimes(2); + }); + + it("redacts reaction on click when myReactionEvent exists", () => { + const myReactionEvent = createReactionEvent("@me:example.org"); + const vm = new ReactionsRowButtonViewModel(createProps({ myReactionEvent })); + + vm.onClick(); + + expect(client.redactEvent).toHaveBeenCalledWith(room.roomId, myReactionEvent.getId()); + expect(client.sendEvent).not.toHaveBeenCalled(); + }); + + it("sends reaction and dispatches message_sent when no myReactionEvent exists", () => { + const vm = new ReactionsRowButtonViewModel(createProps()); + + vm.onClick(); + + expect(client.sendEvent).toHaveBeenCalledWith(room.roomId, EventType.Reaction, { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: mxEvent.getId(), + key: "👍", + }, + }); + expect(dis.dispatch).toHaveBeenCalledWith({ action: "message_sent" }); + }); + + it("does nothing on click when disabled", () => { + const vm = new ReactionsRowButtonViewModel(createProps({ disabled: true })); + + vm.onClick(); + + expect(client.redactEvent).not.toHaveBeenCalled(); + expect(client.sendEvent).not.toHaveBeenCalled(); + expect(dis.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..ac28ab060495d91de6418c4562e46f8ad3e32ae5 GIT binary patch literal 18065 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/selected-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..73cc856f916961c07baf55c612f8315e85f1afd1 GIT binary patch literal 18216 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 X$F`$ZO^8ts6w)>$vn{Eco&WlOWNk96 literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index f1ed7d0c08..865823fb54 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -20,6 +20,7 @@ export * from "./message-body/MediaBody"; export * from "./message-body/MessageTimestampView"; export * from "./message-body/DecryptionFailureBodyView"; export * from "./message-body/ReactionsRowButtonTooltip"; +export * from "./message-body/ReactionsRowButton"; export * from "./message-body/TimelineSeparator/"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css new file mode 100644 index 0000000000..3d1af1a83d --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css @@ -0,0 +1,39 @@ +/* + * 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. + */ + +.reactionsRowButton { + display: inline-flex; + all: unset; + line-height: var(--cpd-font-size-heading-sm); + padding: 1px var(--cpd-space-1-5x); + border: 1px solid var(--cpd-color-gray-400); + border-radius: 10px; + background-color: var(--cpd-color-gray-200); + user-select: none; + align-items: center; +} + +.reactionsRowButtonSelected { + background-color: var(--cpd-color-green-300); + border-color: var(--cpd-color-green-800); +} + +.reactionsRowButtonDisabled { + cursor: not-allowed; +} + +.reactionsRowButtonContent { + max-width: 100px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-right: var(--cpd-space-1x); +} + +.reactionsRowButtonCount { + white-space: nowrap; +} diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx new file mode 100644 index 0000000000..9a2a680c32 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx @@ -0,0 +1,93 @@ +/* + * 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 } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../viewmodel"; +import { type ReactionsRowButtonTooltipViewSnapshot } from "../ReactionsRowButtonTooltip"; +import { + ReactionsRowButtonView, + type ReactionsRowButtonViewSnapshot, + type ReactionsRowButtonViewActions, +} from "./ReactionsRowButtonView"; + +type WrapperProps = Omit & + Partial & { + ariaLabel?: string; + tooltipFormattedSenders?: ReactionsRowButtonTooltipViewSnapshot["formattedSenders"]; + tooltipCaption?: ReactionsRowButtonTooltipViewSnapshot["caption"]; + tooltipOpen?: ReactionsRowButtonTooltipViewSnapshot["tooltipOpen"]; + }; + +const ReactionsRowButtonViewWrapper = ({ + tooltipFormattedSenders, + tooltipCaption, + tooltipOpen, + onClick, + ...snapshotProps +}: WrapperProps): JSX.Element => { + const tooltipVm = useMockedViewModel( + { + formattedSenders: tooltipFormattedSenders, + caption: tooltipCaption, + tooltipOpen, + }, + {}, + ); + + const vm = useMockedViewModel( + { + ...snapshotProps, + tooltipVm, + }, + { + onClick: onClick ?? fn(), + }, + ); + + return ; +}; + +const meta = { + title: "MessageBody/ReactionsRowButton", + component: ReactionsRowButtonViewWrapper, + tags: ["autodocs"], + args: { + content: "👍", + count: 2, + ariaLabel: "Alice and Bob reacted with 👍", + isSelected: false, + isDisabled: false, + imageSrc: undefined, + imageAlt: undefined, + tooltipFormattedSenders: undefined, + tooltipCaption: undefined, + tooltipOpen: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { + args: { + isSelected: true, + }, +}; + +export const WithTooltip: Story = { + args: { + count: 3, + tooltipFormattedSenders: "Alice, Bob and Charlie", + tooltipCaption: ":thumbsup:", + tooltipOpen: true, + }, +}; diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.test.tsx b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.test.tsx new file mode 100644 index 0000000000..0e13963dac --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.test.tsx @@ -0,0 +1,27 @@ +/* + * 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 } from "@test-utils"; +import React from "react"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./ReactionsRowButton.stories"; + +const { Default, Selected } = composeStories(stories); + +describe("ReactionsRowButton", () => { + it("renders the default reaction button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the selected reaction button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx new file mode 100644 index 0000000000..a4ca3d18e5 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButtonView.tsx @@ -0,0 +1,104 @@ +/* + * 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 HTMLAttributes, type JSX } from "react"; +import classNames from "classnames"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; +import { ReactionsRowButtonTooltipView, type ReactionsRowButtonTooltipViewModel } from "../ReactionsRowButtonTooltip"; +import styles from "./ReactionsRowButton.module.css"; + +export interface ReactionsRowButtonViewSnapshot extends Pick< + HTMLAttributes, + "className" | "aria-label" +> { + /** + * The reaction content to display when not using a custom image. + */ + content?: string; + /** + * The total number of reactions for this content. + */ + count: number; + /** + * Whether the reaction button is selected by the current user. + */ + isSelected: boolean; + /** + * Whether the reaction button is disabled. + * @default false + */ + isDisabled?: boolean; + /** + * The image URL to render when using a custom reaction image. + */ + imageSrc?: string; + /** + * The alt text for the custom reaction image. + */ + imageAlt?: string; + /** + * View model for the tooltip wrapper. + */ + tooltipVm: ReactionsRowButtonTooltipViewModel; +} + +export interface ReactionsRowButtonViewActions { + /** + * Called when the user activates the reaction button. + */ + onClick: () => void; +} + +export type ReactionsRowButtonViewModel = ViewModel & ReactionsRowButtonViewActions; + +interface ReactionsRowButtonViewProps { + /** + * The view model for the reactions row button. + */ + vm: ReactionsRowButtonViewModel; +} + +/** + * Renders the reaction button in a reactions row. + */ +export function ReactionsRowButtonView({ vm }: Readonly): JSX.Element { + const snapshot = useViewModel(vm) as ReactionsRowButtonViewSnapshot & { ariaLabel?: string }; + const { content, count, className, isSelected, isDisabled, imageSrc, imageAlt, tooltipVm } = snapshot; + const ariaLabel = snapshot["aria-label"] ?? snapshot.ariaLabel; + const ariaDisabled = isDisabled ? true : undefined; + const classes = classNames(className, styles.reactionsRowButton, { + [styles.reactionsRowButtonSelected]: isSelected, + [styles.reactionsRowButtonDisabled]: isDisabled, + }); + + const reactionContent = imageSrc ? ( + {imageAlt + ) : ( + + ); + + return ( + + + + ); +} diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/__snapshots__/ReactionsRowButton.test.tsx.snap b/packages/shared-components/src/message-body/ReactionsRowButton/__snapshots__/ReactionsRowButton.test.tsx.snap new file mode 100644 index 0000000000..acd1740f5f --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/__snapshots__/ReactionsRowButton.test.tsx.snap @@ -0,0 +1,49 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReactionsRowButton > renders the default reaction button 1`] = ` +
    + +
    +`; + +exports[`ReactionsRowButton > renders the selected reaction button 1`] = ` +
    + +
    +`; diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/index.tsx b/packages/shared-components/src/message-body/ReactionsRowButton/index.tsx new file mode 100644 index 0000000000..85ff09404a --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButton/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 { + ReactionsRowButtonView, + type ReactionsRowButtonViewSnapshot, + type ReactionsRowButtonViewModel, + type ReactionsRowButtonViewActions, +} from "./ReactionsRowButtonView";