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 0000000000..92a5f4d367 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/many-senders-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/many-senders-auto.png new file mode 100644 index 0000000000..237f1a8bfd Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/many-senders-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/no-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/no-tooltip-auto.png new file mode 100644 index 0000000000..dcbe7310d6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/no-tooltip-auto.png differ 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 0000000000..e711eb4649 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx/without-caption-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index b33af6c9ff..6ae20f6069 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -15,6 +15,7 @@ export * from "./composer/Banner"; export * from "./crypto/SasEmoji"; export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; +export * from "./message-body/ReactionsRowButtonTooltip"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; export * from "./room/RoomStatusBar"; diff --git a/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx new file mode 100644 index 0000000000..ff8fa315b5 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionsRowButtonTooltip/ReactionsRowButtonTooltip.stories.tsx @@ -0,0 +1,69 @@ +/* + * 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 PropsWithChildren } from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../viewmodel"; +import { + ReactionsRowButtonTooltipView, + type ReactionsRowButtonTooltipViewSnapshot, +} from "./ReactionsRowButtonTooltipView"; + +type WrapperProps = ReactionsRowButtonTooltipViewSnapshot & PropsWithChildren; + +const ReactionsRowButtonTooltipViewWrapper = ({ children, ...snapshotProps }: WrapperProps): JSX.Element => { + 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"); + }); +});