Skip to content
Draft
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from "./event-tiles/EventTileBubble";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/ReactionRow";
export * from "./message-body/ReactionsRowButtonTooltip";
export * from "./message-body/TimelineSeparator/";
export * from "./pill-input/Pill";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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.
*/

.reactionRow {
color: var(--cpd-color-text-primary);
}

.reactionButton {
align-items: center;
background-color: var(--cpd-color-gray-200);
border: 1px solid var(--cpd-color-gray-400);
border-radius: 10px;
display: inline-flex;
line-height: 20px;
padding: 1px 6px;
}

.reactionButtonContent {
padding-right: 4px;
}

.addReactionButton,
.reactionRow :global(.mx_ReactionsRow_addReactionButton) {
align-items: center;
background: transparent;
border: 0;
display: inline-flex;
font-size: 20px;
justify-content: center;
line-height: 0;
visibility: hidden;
width: 20px;
height: 20px;
padding: var(--cpd-space-1x);
vertical-align: middle;
margin-left: var(--cpd-space-1x);
margin-right: var(--cpd-space-1x);
}

.addReactionButton svg,
.reactionRow :global(.mx_ReactionsRow_addReactionButton) svg {
display: block;
height: 1em;
width: 1em;
color: var(--cpd-color-icon-secondary);
}

.addReactionButtonActive,
.reactionRow :global(.mx_ReactionsRow_addReactionButton.mx_ReactionsRow_addReactionButton_active) {
visibility: visible;
}

.reactionRow:hover .addReactionButton,
:global(.mx_EventTile:hover) .reactionRow :global(.mx_ReactionsRow_addReactionButton) {
visibility: visible;
}

.addReactionButton:hover svg,
.addReactionButtonActive svg,
.reactionRow :global(.mx_ReactionsRow_addReactionButton:hover) svg,
.reactionRow :global(.mx_ReactionsRow_addReactionButton.mx_ReactionsRow_addReactionButton_active) svg {
color: var(--cpd-color-icon-primary);
}

.showAllButton {
background: transparent;
border: 0;
color: var(--cpd-color-text-secondary);
margin-inline-start: var(--cpd-space-1x);
padding: 0;
vertical-align: middle;
font: var(--cpd-font-body-xs-regular);
line-height: 20px;
}

.showAllButton:hover {
color: var(--cpd-color-text-primary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";

import type { Meta, StoryFn } from "@storybook/react-vite";
import { useMockedViewModel } from "../../viewmodel";
import styles from "./ReactionRow.module.css";
import { ReactionRowView, type ReactionRowViewActions, type ReactionRowViewSnapshot } from "./ReactionRowView";

type ReactionRowProps = ReactionRowViewSnapshot & ReactionRowViewActions;

const ReactionRowViewWrapper = ({ onShowAllClick, ...snapshot }: ReactionRowProps): JSX.Element => {
const vm = useMockedViewModel(snapshot, { onShowAllClick });
return <ReactionRowView vm={vm} />;
};

export default {
title: "MessageBody/ReactionRow",
component: ReactionRowViewWrapper,
tags: ["autodocs"],
argTypes: {
isVisible: { control: "boolean" },
showAllVisible: { control: "boolean" },
showAllLabel: { control: "text" },
toolbarAriaLabel: { control: "text" },
},
args: {
onShowAllClick: fn(),
},
} as Meta<typeof ReactionRowViewWrapper>;

const Template: StoryFn<typeof ReactionRowViewWrapper> = (args) => <ReactionRowViewWrapper {...args} />;

export const Default = Template.bind({});
Default.args = {
isVisible: true,
items: [
<button key="1" className={styles.reactionButton} type="button">
<span className={styles.reactionButtonContent}>👍</span>
<span>3</span>
</button>,
<button key="2" className={styles.reactionButton} type="button">
<span className={styles.reactionButtonContent}>🎉</span>
<span>2</span>
</button>,
],
showAllVisible: false,
showAllLabel: "Show all",
toolbarAriaLabel: "Reactions",
addReactionButton: (
<button className={styles.addReactionButton} aria-label="Add reaction" type="button">
<ReactionAddIcon />
</button>
),
};

export const ActiveAddReactionButton = Template.bind({});
ActiveAddReactionButton.args = {
...Default.args,
addReactionButton: (
<button
className={`${styles.addReactionButton} ${styles.addReactionButtonActive}`}
aria-label="Add reaction"
type="button"
>
<ReactionAddIcon />
</button>
),
};

export const Hidden = Template.bind({});
Hidden.args = {
...Default.args,
isVisible: false,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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 React from "react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@test-utils";

import * as stories from "./ReactionRow.stories";
import { MockViewModel } from "../../viewmodel/MockViewModel";
import { ReactionRowView, type ReactionRowViewActions, type ReactionRowViewSnapshot } from "./ReactionRowView";

const { Default, ActiveAddReactionButton, Hidden } = composeStories(stories);

describe("ReactionRowView", () => {
it("renders the default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});

it("renders with active add-reaction button", () => {
const { container } = render(<ActiveAddReactionButton />);
expect(container).toMatchSnapshot();
});

it("renders nothing when hidden", () => {
const { container } = render(<Hidden />);
expect(container).toMatchSnapshot();
});

class ReactionRowViewModel extends MockViewModel<ReactionRowViewSnapshot> implements ReactionRowViewActions {
public readonly onShowAllClick: ReactionRowViewActions["onShowAllClick"];

public constructor(snapshot: ReactionRowViewSnapshot, actions: ReactionRowViewActions) {
super(snapshot);
this.onShowAllClick = actions.onShowAllClick;
}
}

it("calls onShowAllClick when show-all is clicked", async () => {
const onShowAllClick = vi.fn();
const vm = new ReactionRowViewModel(
{
isVisible: true,
items: [<button key="1">👍 1</button>],
showAllVisible: true,
showAllLabel: "Show all",
toolbarAriaLabel: "Reactions",
},
{ onShowAllClick },
);

const user = userEvent.setup();
render(<ReactionRowView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Show all" }));

expect(onShowAllClick).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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 ReactNode } from "react";
import classNames from "classnames";

import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./ReactionRow.module.css";

export interface ReactionRowViewSnapshot {
/**
* Whether the row should be shown.
*/
isVisible: boolean;
/**
* Rendered reaction items.
*/
items: ReactNode[];
/**
* The CSS class name.
*/
className?: string[];
/**
* Whether to display the show-all button.
*/
showAllVisible: boolean;
/**
* Show-all button label, pre-translated.
*/
showAllLabel: string;
/**
* Toolbar aria-label, pre-translated.
*/
toolbarAriaLabel: string;
/**
* Optional add-reaction button element.
*/
addReactionButton?: ReactNode;
}

export interface ReactionRowViewActions {
/**
* Triggered when the show-all button is clicked.
*/
onShowAllClick: () => void;
}

export type ReactionRowViewModel = ViewModel<ReactionRowViewSnapshot> & ReactionRowViewActions;

interface ReactionRowViewProps {
vm: ReactionRowViewModel;
}

export function ReactionRowView({ vm }: Readonly<ReactionRowViewProps>): JSX.Element {
const { isVisible, items, className, showAllVisible, showAllLabel, toolbarAriaLabel, addReactionButton } =
useViewModel(vm);

if (!isVisible) {
return <></>;
}

return (
<div className={classNames("mx_ReactionsRow", styles.reactionRow)} role="toolbar" aria-label={toolbarAriaLabel}>
{items}
{showAllVisible && (
<button
type="button"
className={classNames(className, styles.showAllButton)}
onClick={vm.onShowAllClick}
>
{showAllLabel}
</button>
)}
{addReactionButton}
</div>
);
}
Loading
Loading