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 0000000000..ac28ab0604 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/default-auto.png differ 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 0000000000..73cc856f91 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/selected-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/with-tooltip-auto.png new file mode 100644 index 0000000000..ec05cbd849 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionsRowButton/ReactionsRowButton.stories.tsx/with-tooltip-auto.png differ 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";