From 62c7fe54089c9c291dbe8713a6013cbd04127983 Mon Sep 17 00:00:00 2001 From: Zack Date: Fri, 30 Jan 2026 12:53:57 +0100 Subject: [PATCH] Refactor ReactionsRowButtonTooltip to shared-components (#31866) * Setting up structure for the init refactoring of ReactionsRowButtonTooltip * implemented example to follow for refactoring to MVVM * Refactoring of ReactionsRowButtonTooltipView * updated reactionrowbutton to use our new viewmodel and removed unessecery comments * Updated children from reactnode to propswithchildren * removal of children on the vm have it as a props * implemented constructor into reactionrowbutton to use vm to viewmodel * Removal of old component * Added ViewModel Tests for new viewmodel * Fix issues after merging develop * Updated import placement for eslint failure CI * Add tests for ReactionsRowButton ViewModel integration and click handlers to pass coverage * Added more tests to cover all conditions * Pass MatrixClient as prop instead of using global; replace expect(true).toBe(true) with not.toThrow() * Added new snapshot to reflect modifications on tests * Update images to fit the CI tests * Optimize reactions tooltip viewmodel updates * Removal of module.css for reactionbuttontooltip, we dont need it since we dont use any css * Fixed snapshots to show the tooltip by introducing a boolean to set open to true in Storybook. * Update snapshots --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- .../default-auto.png | Bin 0 -> 20621 bytes .../many-senders-auto.png | Bin 0 -> 22103 bytes .../no-tooltip-auto.png | Bin 0 -> 4225 bytes .../without-caption-auto.png | Bin 0 -> 19340 bytes packages/shared-components/src/index.ts | 1 + .../ReactionsRowButtonTooltip.stories.tsx | 69 +++ .../ReactionsRowButtonTooltip.test.tsx | 27 ++ .../ReactionsRowButtonTooltipView.tsx | 63 +++ .../ReactionsRowButtonTooltip.test.tsx.snap | 21 + .../ReactionsRowButtonTooltip/index.tsx | 12 + .../views/messages/ReactionsRowButton.tsx | 47 +- .../messages/ReactionsRowButtonTooltip.tsx | 62 --- .../ReactionsRowButtonTooltipViewModel.ts | 113 +++++ .../messages/ReactionsRowButton-test.tsx | 427 +++++++++++++++++- ...eactionsRowButtonTooltipViewModel-test.tsx | 172 +++++++ 15 files changed, 941 insertions(+), 73 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/many-senders-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/no-tooltip-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/without-caption-auto.png create mode 100644 packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.test.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltipView.tsx create mode 100644 packages/shared-components/src/message-body/ReactionsRowButtonTooltip/__snapshots__/ReactionsRowButtonTooltip.test.tsx.snap create mode 100644 packages/shared-components/src/message-body/ReactionsRowButtonTooltip/index.tsx delete mode 100644 src/components/views/messages/ReactionsRowButtonTooltip.tsx create mode 100644 src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts create mode 100644 test/viewmodels/message-body/ReactionsRowButtonTooltipViewModel-test.tsx diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..92a5f4d3670ff4d2e98fe45f35aba17c500bd055 GIT binary patch literal 20621 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

w?W0&-raV?83D#p{DSM7LPXKMiVQ7)! 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/without-caption-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/without-caption-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..e711eb4649388a3a1c2ea5892323702bebde04a5 GIT binary patch literal 19340 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 { + const vm = useMockedViewModel(snapshotProps, {}); + return {children}; +}; + +export default { + title: "MessageBody/ReactionsRowButtonTooltip", + component: ReactionsRowButtonTooltipViewWrapper, + tags: ["autodocs"], + argTypes: { + formattedSenders: { control: "text" }, + caption: { control: "text" }, + }, + args: { + children: , + }, +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); +Default.args = { + formattedSenders: "Alice, Bob and Charlie", + caption: ":thumbsup:", + tooltipOpen: true, +}; + +export const ManySenders = Template.bind({}); +ManySenders.args = { + formattedSenders: "Alice, Bob, Charlie, David, Eve, Frank and 2 others", + caption: ":heart:", + children: , + tooltipOpen: true, +}; + +export const WithoutCaption = Template.bind({}); +WithoutCaption.args = { + formattedSenders: "Alice and Bob", + caption: undefined, + children: , + tooltipOpen: true, +}; + +export const NoTooltip = Template.bind({}); +NoTooltip.args = { + formattedSenders: undefined, + caption: undefined, + children: , +}; diff --git a/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.test.tsx b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.test.tsx new file mode 100644 index 0000000000..4c48d0d789 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.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 "./ReactionsRowButtonTooltip.stories"; + +const { Default, ManySenders } = composeStories(stories); + +describe("ReactionsRowButtonTooltip", () => { + it("renders the tooltip with formatted senders and caption", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the tooltip with many senders", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltipView.tsx b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltipView.tsx new file mode 100644 index 0000000000..b8eb50f03c --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltipView.tsx @@ -0,0 +1,63 @@ +/* + * 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 PropsWithChildren, type JSX } from "react"; +import React from "react"; +import { Tooltip } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../viewmodel"; + +/** + * Snapshot interface for the ReactionsRowButtonTooltip view. + */ +export interface ReactionsRowButtonTooltipViewSnapshot { + /** + * The formatted list of sender names who reacted. + */ + formattedSenders?: string; + /** + * The caption to display (e.g., the shortcode of the reaction). + */ + caption?: string; + /** + * Whether the tooltip should be forced open. + */ + tooltipOpen?: boolean; +} + +export type ReactionsRowButtonTooltipViewModel = ViewModel; + +interface ReactionsRowButtonTooltipViewProps { + /** + * The view model for the reactions row button tooltip. + */ + vm: ReactionsRowButtonTooltipViewModel; + /** + * The children to wrap with the tooltip. + */ + children?: PropsWithChildren["children"]; +} + +/** + * Type alias for the ReactionsRowButtonTooltip view model. + */ +export function ReactionsRowButtonTooltipView({ + vm, + children, +}: Readonly): JSX.Element { + const { formattedSenders, caption, tooltipOpen } = useViewModel(vm); + + if (formattedSenders) { + return ( + + {children} + + ); + } + + return <>{children}; +} diff --git a/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/__snapshots__/ReactionsRowButtonTooltip.test.tsx.snap b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/__snapshots__/ReactionsRowButtonTooltip.test.tsx.snap new file mode 100644 index 0000000000..4940b975dd --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/__snapshots__/ReactionsRowButtonTooltip.test.tsx.snap @@ -0,0 +1,21 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReactionsRowButtonTooltip > renders the tooltip with formatted senders and caption 1`] = ` +

+ +
+`; + +exports[`ReactionsRowButtonTooltip > renders the tooltip with many senders 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/index.tsx b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/index.tsx new file mode 100644 index 0000000000..92a8a8d611 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/index.tsx @@ -0,0 +1,12 @@ +/* + * 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 { + ReactionsRowButtonTooltipView, + type ReactionsRowButtonTooltipViewSnapshot, + type ReactionsRowButtonTooltipViewModel, +} from "./ReactionsRowButtonTooltipView"; diff --git a/src/components/views/messages/ReactionsRowButton.tsx b/src/components/views/messages/ReactionsRowButton.tsx index 8320237b25..9147d7c1fc 100644 --- a/src/components/views/messages/ReactionsRowButton.tsx +++ b/src/components/views/messages/ReactionsRowButton.tsx @@ -9,12 +9,13 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; import { EventType, type MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { ReactionsRowButtonTooltipView } 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 ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip"; +import { ReactionsRowButtonTooltipViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonTooltipViewModel"; import AccessibleButton from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; @@ -40,6 +41,41 @@ export default class ReactionsRowButton extends React.PureComponent { 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) { @@ -110,12 +146,7 @@ export default class ReactionsRowButton extends React.PureComponent { } return ( - + { {count} - + ); } } diff --git a/src/components/views/messages/ReactionsRowButtonTooltip.tsx b/src/components/views/messages/ReactionsRowButtonTooltip.tsx deleted file mode 100644 index f40002deff..0000000000 --- a/src/components/views/messages/ReactionsRowButtonTooltip.tsx +++ /dev/null @@ -1,62 +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, { type PropsWithChildren } from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { Tooltip } from "@vector-im/compound-web"; - -import { unicodeToShortcode } from "../../../HtmlUtils"; -import { _t } from "../../../languageHandler"; -import { formatList } from "../../../utils/FormattingUtils"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { REACTION_SHORTCODE_KEY } from "./ReactionsRow"; -interface IProps { - // The event we're displaying reactions for - mxEvent: MatrixEvent; - // The reaction content / key / emoji - content: string; - // A list of Matrix reaction events for this key - reactionEvents: MatrixEvent[]; - // Whether to render custom image reactions - customReactionImagesEnabled?: boolean; -} - -export default class ReactionsRowButtonTooltip extends React.PureComponent> { - public static contextType = MatrixClientContext; - declare public context: React.ContextType; - - public render(): React.ReactNode { - const { content, reactionEvents, mxEvent, children } = this.props; - - const room = this.context.getRoom(mxEvent.getRoomId()); - if (room) { - const senders: string[] = []; - let customReactionName: string | undefined; - for (const reactionEvent of reactionEvents) { - const member = room.getMember(reactionEvent.getSender()!); - const name = member?.name ?? reactionEvent.getSender()!; - senders.push(name); - customReactionName = - (this.props.customReactionImagesEnabled && - REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) || - undefined; - } - const shortName = unicodeToShortcode(content) || customReactionName; - const formattedSenders = formatList(senders, 6); - const caption = shortName ? _t("timeline|reactions|tooltip_caption", { shortName }) : undefined; - - return ( - - {children} - - ); - } - - return children; - } -} diff --git a/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts b/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts new file mode 100644 index 0000000000..baa7b77855 --- /dev/null +++ b/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts @@ -0,0 +1,113 @@ +/* + * 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 MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type ReactionsRowButtonTooltipViewSnapshot, + type ReactionsRowButtonTooltipViewModel as ReactionsRowButtonTooltipViewModelInterface, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../languageHandler"; +import { formatList } from "../../utils/FormattingUtils"; +import { unicodeToShortcode } from "../../HtmlUtils"; +import { REACTION_SHORTCODE_KEY } from "../../components/views/messages/ReactionsRow"; + +export interface ReactionsRowButtonTooltipViewModelProps { + /** + * The Matrix client instance. + */ + client: MatrixClient | null; + /** + * The event we're displaying reactions for. + */ + mxEvent: MatrixEvent; + /** + * The reaction content / key / emoji. + */ + content: string; + /** + * A list of Matrix reaction events for this key. + */ + reactionEvents: MatrixEvent[]; + /** + * Whether to render custom image reactions. + */ + customReactionImagesEnabled?: boolean; +} + +/** + * ViewModel for the reactions row button tooltip, providing the formatted sender list and caption. + */ +export class ReactionsRowButtonTooltipViewModel + extends BaseViewModel + implements ReactionsRowButtonTooltipViewModelInterface +{ + /** + * Computes the snapshot for the reactions row button tooltip. + * @param props - The view model properties + * @returns The computed snapshot with formattedSenders, caption, and children + */ + private static readonly computeSnapshot = ( + props: ReactionsRowButtonTooltipViewModelProps, + ): ReactionsRowButtonTooltipViewSnapshot => { + const { client, mxEvent, content, reactionEvents, customReactionImagesEnabled } = props; + + const room = client?.getRoom(mxEvent.getRoomId()); + + if (room) { + const senders: string[] = []; + let customReactionName: string | undefined; + + for (const reactionEvent of reactionEvents) { + const member = room.getMember(reactionEvent.getSender()!); + const name = member?.name ?? reactionEvent.getSender()!; + senders.push(name); + customReactionName = + (customReactionImagesEnabled && REACTION_SHORTCODE_KEY.findIn(reactionEvent.getContent())) || + undefined; + } + + const shortName = unicodeToShortcode(content) || customReactionName; + const formattedSenders = formatList(senders, 6); + const caption = shortName ? _t("timeline|reactions|tooltip_caption", { shortName }) : undefined; + + return { + formattedSenders, + caption, + }; + } + + return { + formattedSenders: undefined, + caption: undefined, + }; + }; + + public constructor(props: ReactionsRowButtonTooltipViewModelProps) { + super(props, ReactionsRowButtonTooltipViewModel.computeSnapshot(props)); + } + + /** + * Updates the properties of the view model and recomputes the snapshot. + * @param newProps - Partial properties to update + */ + public setProps(newProps: Partial): void { + this.props = { ...this.props, ...newProps }; + const nextSnapshot = ReactionsRowButtonTooltipViewModel.computeSnapshot(this.props); + const currentSnapshot = this.snapshot.current; + + if ( + nextSnapshot.formattedSenders === currentSnapshot.formattedSenders && + nextSnapshot.caption === currentSnapshot.caption + ) { + return; + } + + this.snapshot.set(nextSnapshot); + } +} diff --git a/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx b/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx index 3b4298a61c..ef6fa3ba61 100644 --- a/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx +++ b/test/unit-tests/components/views/messages/ReactionsRowButton-test.tsx @@ -7,19 +7,38 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { type IContent, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import { render } from "jest-matrix-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({ - mxcUrlToHttp: jest.fn().mockReturnValue("https://not.a.real.url"), getRoom: jest.fn(), + sendEvent: jest.fn().mockResolvedValue({ event_id: "$sent_event" }), + redactEvent: jest.fn().mockResolvedValue({}), }); const room = new Room(roomId, mockClient, userId); @@ -52,6 +71,10 @@ describe("ReactionsRowButton", () => { 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", () => { @@ -122,4 +145,402 @@ describe("ReactionsRowButton", () => { 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/test/viewmodels/message-body/ReactionsRowButtonTooltipViewModel-test.tsx b/test/viewmodels/message-body/ReactionsRowButtonTooltipViewModel-test.tsx new file mode 100644 index 0000000000..a4b742b208 --- /dev/null +++ b/test/viewmodels/message-body/ReactionsRowButtonTooltipViewModel-test.tsx @@ -0,0 +1,172 @@ +/* + * 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 MatrixClient, type MatrixEvent, type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; + +import { + ReactionsRowButtonTooltipViewModel, + type ReactionsRowButtonTooltipViewModelProps, +} from "../../../src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel"; +import { stubClient, mkStubRoom, mkEvent } from "../../test-utils"; +import { unicodeToShortcode } from "../../../src/HtmlUtils"; + +jest.mock("../../../src/HtmlUtils", () => ({ + ...jest.requireActual("../../../src/HtmlUtils"), + unicodeToShortcode: jest.fn(), +})); + +const mockedUnicodeToShortcode = jest.mocked(unicodeToShortcode); + +describe("ReactionsRowButtonTooltipViewModel", () => { + let client: MatrixClient; + let room: Room; + let mxEvent: MatrixEvent; + + const createReactionEvent = (senderId: string, content?: Record): 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: "👍" }, + ...content, + }, + }); + }; + + const createProps = ( + overrides?: Partial, + ): ReactionsRowButtonTooltipViewModelProps => ({ + client, + mxEvent, + content: "👍", + reactionEvents: [], + customReactionImagesEnabled: false, + ...overrides, + }); + + beforeEach(() => { + client = stubClient(); + 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" }, + }); + + mockedUnicodeToShortcode.mockImplementation((char: string) => { + if (char === "👍") return ":thumbsup:"; + return ""; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUnicodeToShortcode.mockReset(); + }); + + it("should return undefined snapshot when room is not found", () => { + jest.spyOn(client, "getRoom").mockReturnValue(null); + + const vm = new ReactionsRowButtonTooltipViewModel(createProps()); + const snapshot = vm.getSnapshot(); + + expect(snapshot.formattedSenders).toBeUndefined(); + expect(snapshot.caption).toBeUndefined(); + }); + + it("should return undefined snapshot when MatrixClient is unavailable", () => { + const vm = new ReactionsRowButtonTooltipViewModel(createProps({ client: null })); + const snapshot = vm.getSnapshot(); + + expect(snapshot.formattedSenders).toBeUndefined(); + expect(snapshot.caption).toBeUndefined(); + }); + + it("should compute formattedSenders and caption from reaction events", () => { + const reactionEvent = createReactionEvent("@alice:example.org"); + jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember); + + const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] })); + const snapshot = vm.getSnapshot(); + + expect(snapshot.formattedSenders).toBe("Alice"); + expect(snapshot.caption).toContain(":thumbsup:"); + }); + + it("should fall back to sender ID when member is not found", () => { + const reactionEvent = createReactionEvent("@unknown:example.org"); + jest.spyOn(room, "getMember").mockReturnValue(null); + + const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] })); + + expect(vm.getSnapshot().formattedSenders).toBe("@unknown:example.org"); + }); + + it("should use custom reaction shortcode when customReactionImagesEnabled is true", () => { + mockedUnicodeToShortcode.mockReturnValue(""); + const reactionEvent = createReactionEvent("@alice:example.org", { + "com.beeper.reaction.shortcode": "custom_emoji", + }); + jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember); + + const vm = new ReactionsRowButtonTooltipViewModel( + createProps({ + content: "mxc://custom/emoji", + reactionEvents: [reactionEvent], + customReactionImagesEnabled: true, + }), + ); + + expect(vm.getSnapshot().caption).toContain("custom_emoji"); + }); + + it("should not use custom reaction shortcode when customReactionImagesEnabled is false", () => { + mockedUnicodeToShortcode.mockReturnValue(""); + const reactionEvent = createReactionEvent("@alice:example.org", { + "com.beeper.reaction.shortcode": "custom_emoji", + }); + jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember); + + const vm = new ReactionsRowButtonTooltipViewModel( + createProps({ + content: "mxc://custom/emoji", + reactionEvents: [reactionEvent], + customReactionImagesEnabled: false, + }), + ); + + expect(vm.getSnapshot().caption).toBeUndefined(); + }); + + it("should update snapshot and notify subscribers when setProps is called", () => { + const aliceReaction = createReactionEvent("@alice:example.org"); + const bobReaction = createReactionEvent("@bob:example.org"); + + jest.spyOn(room, "getMember").mockImplementation((userId) => { + const names: Record = { "@alice:example.org": "Alice", "@bob:example.org": "Bob" }; + return names[userId!] ? ({ name: names[userId!], userId } as RoomMember) : null; + }); + + const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [aliceReaction] })); + expect(vm.getSnapshot().formattedSenders).toBe("Alice"); + + const subscriber = jest.fn(); + vm.subscribe(subscriber); + + vm.setProps({ reactionEvents: [aliceReaction, bobReaction] }); + + expect(subscriber).toHaveBeenCalled(); + expect(vm.getSnapshot().formattedSenders).toContain("Alice"); + expect(vm.getSnapshot().formattedSenders).toContain("Bob"); + }); +});