Move ReactionRow To Shared Components MVVM (#32634)

* Init shared component structure

* Storybook implementation

* Add snapshots of storybook examples

* ViewModel Creation + Implementation In EventTile.tsx

* Prettier

* Update HTML snapshot

* Add onhover pointer on bottons

* Added compound web tooltip

* Removed possible of undefined on label

* Update snapshots

* Update setters to use merge instead of updating full snapshot

* adapt view model test for setters change

* Actions should be passed to viewmodel fix

* replace ReactionsRowWrapper forceRender with explicit reaction state

* Update snapshot
This commit is contained in:
Zack 2026-03-02 14:59:04 +01:00 committed by GitHub
parent 11030ae68d
commit fe84501e95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1175 additions and 290 deletions

View File

@ -1,282 +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, { 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 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;
export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode");
const ReactButton: React.FC<IProps> = ({ mxEvent, reactions }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && button.current) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = (
<ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false} focusLock>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className={classNames("mx_ReactionsRow_addReactionButton", {
mx_ReactionsRow_addReactionButton_active: menuDisplayed,
})}
title={_t("timeline|reactions|add_reaction_prompt")}
onClick={openMenu}
onContextMenu={(e: SyntheticEvent): void => {
e.preventDefault();
openMenu();
}}
isExpanded={menuDisplayed}
ref={button}
>
<ReactionAddIcon />
</ContextMenuTooltipButton>
{contextMenu}
</React.Fragment>
);
};
interface ReactionsRowButtonItemProps {
mxEvent: MatrixEvent;
content: string;
count: number;
reactionEvents: MatrixEvent[];
myReactionEvent?: MatrixEvent;
disabled?: boolean;
customReactionImagesEnabled?: boolean;
}
const ReactionsRowButtonItem: React.FC<ReactionsRowButtonItemProps> = (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 <ReactionsRowButtonView vm={vm} />;
};
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations | null | undefined;
}
interface IState {
myReactions: MatrixEvent[] | null;
showAll: boolean;
}
export default class ReactionsRow extends React.PureComponent<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
myReactions: this.getMyReactions(),
showAll: false,
};
}
public componentDidMount(): void {
const { mxEvent, reactions } = this.props;
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
mxEvent.once(MatrixEventEvent.Decrypted, this.onDecrypted);
}
if (reactions) {
reactions.on(RelationsEvent.Add, this.onReactionsChange);
reactions.on(RelationsEvent.Remove, this.onReactionsChange);
reactions.on(RelationsEvent.Redaction, this.onReactionsChange);
}
}
public componentWillUnmount(): void {
const { mxEvent, reactions } = this.props;
mxEvent.off(MatrixEventEvent.Decrypted, this.onDecrypted);
if (reactions) {
reactions.off(RelationsEvent.Add, this.onReactionsChange);
reactions.off(RelationsEvent.Remove, this.onReactionsChange);
reactions.off(RelationsEvent.Redaction, this.onReactionsChange);
}
}
public componentDidUpdate(prevProps: IProps): void {
if (this.props.reactions && prevProps.reactions !== this.props.reactions) {
this.props.reactions.on(RelationsEvent.Add, this.onReactionsChange);
this.props.reactions.on(RelationsEvent.Remove, this.onReactionsChange);
this.props.reactions.on(RelationsEvent.Redaction, this.onReactionsChange);
this.onReactionsChange();
}
}
private onDecrypted = (): void => {
// Decryption changes whether the event is actionable
this.forceUpdate();
};
private onReactionsChange = (): void => {
this.setState({
myReactions: this.getMyReactions(),
});
// Using `forceUpdate` for the moment, since we know the overall set of reactions
// has changed (this is triggered by events for that purpose only) and
// `PureComponent`s shallow state / props compare would otherwise filter this out.
this.forceUpdate();
};
private getMyReactions(): MatrixEvent[] | null {
const reactions = this.props.reactions;
if (!reactions) {
return null;
}
const userId = this.context.room?.client.getUserId();
if (!userId) return null;
const myReactions = reactions.getAnnotationsBySender()?.[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
}
private onShowAllClick = (): void => {
this.setState({
showAll: true,
});
};
public render(): React.ReactNode {
const { mxEvent, reactions } = this.props;
const { myReactions, showAll } = this.state;
if (!reactions || !isContentActionable(mxEvent)) {
return null;
}
const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images");
let items = reactions
.getSortedAnnotationsByKey()
?.map(([content, events]) => {
const count = events.size;
if (!count) {
return null;
}
// Deduplicate the events as per the spec https://spec.matrix.org/v1.7/client-server-api/#annotations-client-behaviour
// This isn't done by the underlying data model as applications may still need access to the whole list of events
// for moderation purposes.
const deduplicatedEvents = uniqBy([...events], (e) => e.getSender());
const myReactionEvent = myReactions?.find((mxEvent) => {
if (mxEvent.isRedacted()) {
return false;
}
return mxEvent.getRelation()?.key === content;
});
return (
<ReactionsRowButtonItem
key={content}
content={content}
count={deduplicatedEvents.length}
mxEvent={mxEvent}
reactionEvents={deduplicatedEvents}
myReactionEvent={myReactionEvent}
customReactionImagesEnabled={customReactionImagesEnabled}
disabled={
!this.context.canReact ||
(myReactionEvent && !myReactionEvent.isRedacted() && !this.context.canSelfRedact)
}
/>
);
})
.filter((item) => !!item);
if (!items?.length) return null;
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
// The "+ 1" ensure that the "show all" reveals something that takes up
// more space than the button itself.
let showAllButton: JSX.Element | undefined;
if (items.length > MAX_ITEMS_WHEN_LIMITED + 1 && !showAll) {
items = items.slice(0, MAX_ITEMS_WHEN_LIMITED);
showAllButton = (
<AccessibleButton kind="link_inline" className="mx_ReactionsRow_showAll" onClick={this.onShowAllClick}>
{_t("action|show_all")}
</AccessibleButton>
);
}
let addReactionButton: JSX.Element | undefined;
if (this.context.canReact) {
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
}
return (
<div className="mx_ReactionsRow" role="toolbar" aria-label={_t("common|reactions")}>
{items}
{showAllButton}
{addReactionButton}
</div>
);
}
}

View File

@ -7,7 +7,18 @@ 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, { createRef, useContext, useEffect, type JSX, type Ref, type MouseEvent, type ReactNode } from "react";
import React, {
createRef,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type JSX,
type Ref,
type MouseEvent,
type ReactNode,
} from "react";
import classNames from "classnames";
import {
EventStatus,
@ -19,6 +30,7 @@ import {
type Relations,
type RelationType,
type Room,
RelationsEvent,
RoomEvent,
type RoomMember,
type Thread,
@ -34,12 +46,15 @@ import {
type UserVerificationStatus,
} from "matrix-js-sdk/src/crypto-api";
import { Tooltip } from "@vector-im/compound-web";
import { uniqueId } from "lodash";
import { uniqueId, uniqBy } from "lodash";
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
useCreateAutoDisposedViewModel,
DecryptionFailureBodyView,
MessageTimestampView,
ReactionsRowButtonView,
ReactionsRowView,
useViewModel,
} from "@element-hq/web-shared-components";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
@ -50,7 +65,7 @@ import { Layout } from "../../../settings/enums/Layout";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RoomAvatar from "../avatars/RoomAvatar";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveRightOf } from "../../structures/ContextMenu";
import ContextMenu, { aboveLeftOf, aboveRightOf } from "../../structures/ContextMenu";
import { objectHasDiff } from "../../../utils/objects";
import type EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
@ -64,8 +79,9 @@ import MemberAvatar from "../avatars/MemberAvatar";
import SenderProfile from "../messages/SenderProfile";
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from "../messages/ReactionsRow";
import ReactionPicker from "../emojipicker/ReactionPicker";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import { isContentActionable } from "../../../utils/EventUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton";
@ -92,10 +108,14 @@ import { ElementCallEventType } from "../../../call-types";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
import SettingsStore from "../../../settings/SettingsStore";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
} from "../../../viewmodels/message-body/MessageTimestampViewModel.ts";
import { ReactionsRowButtonViewModel } from "../../../viewmodels/message-body/ReactionsRowButtonViewModel";
import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel } from "../../../viewmodels/message-body/ReactionsRowViewModel";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
export type GetRelationsForEvent = (
eventId: string,
@ -1196,7 +1216,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
let reactionsRow: JSX.Element | undefined;
if (!isRedacted) {
reactionsRow = (
<ReactionsRow
<ReactionsRowWrapper
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
key="mx_EventTile_reactionsRow"
@ -1627,3 +1647,239 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
</>
);
}
interface ReactionsRowButtonItemProps {
mxEvent: MatrixEvent;
content: string;
count: number;
reactionEvents: MatrixEvent[];
myReactionEvent?: MatrixEvent;
disabled?: boolean;
customReactionImagesEnabled?: boolean;
}
function ReactionsRowButtonItem(props: Readonly<ReactionsRowButtonItemProps>): JSX.Element {
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 <ReactionsRowButtonView vm={vm} />;
}
interface ReactionGroup {
content: string;
events: MatrixEvent[];
}
const getReactionGroups = (reactions?: Relations | null): ReactionGroup[] =>
reactions
?.getSortedAnnotationsByKey()
?.map(([content, events]) => ({
content,
events: [...events],
}))
.filter(({ events }) => events.length > 0) ?? [];
const getMyReactions = (reactions: Relations | null | undefined, userId?: string): MatrixEvent[] | null => {
if (!reactions || !userId) {
return null;
}
const myReactions = reactions.getAnnotationsBySender()?.[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
};
interface ReactionsRowWrapperProps {
mxEvent: MatrixEvent;
reactions?: Relations | null;
}
function ReactionsRowWrapper({ mxEvent, reactions }: Readonly<ReactionsRowWrapperProps>): JSX.Element | null {
const roomContext = useContext(RoomContext);
const userId = roomContext.room?.client.getUserId() ?? undefined;
const [reactionGroups, setReactionGroups] = useState<ReactionGroup[]>(() => getReactionGroups(reactions));
const [myReactions, setMyReactions] = useState<MatrixEvent[] | null>(() => getMyReactions(reactions, userId));
const [menuDisplayed, setMenuDisplayed] = useState(false);
const [menuAnchorRect, setMenuAnchorRect] = useState<DOMRect | null>(null);
const vm = useCreateAutoDisposedViewModel(
() =>
new ReactionsRowViewModel({
isActionable: isContentActionable(mxEvent),
reactionGroupCount: reactionGroups.length,
canReact: roomContext.canReact,
addReactionButtonActive: false,
}),
);
const openReactionMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>): void => {
setMenuAnchorRect(event.currentTarget.getBoundingClientRect());
setMenuDisplayed(true);
}, []);
const closeReactionMenu = useCallback((): void => {
setMenuDisplayed(false);
}, []);
const updateReactionsState = useCallback((): void => {
const nextReactionGroups = getReactionGroups(reactions);
setReactionGroups(nextReactionGroups);
setMyReactions(getMyReactions(reactions, userId));
vm.setReactionGroupCount(nextReactionGroups.length);
}, [reactions, userId, vm]);
useEffect(() => {
vm.setActionable(isContentActionable(mxEvent));
}, [mxEvent, vm]);
useEffect(() => {
vm.setCanReact(roomContext.canReact);
if (!roomContext.canReact && menuDisplayed) {
setMenuDisplayed(false);
}
}, [roomContext.canReact, menuDisplayed, vm]);
useEffect(() => {
vm.setAddReactionHandlers({
onAddReactionClick: openReactionMenu,
onAddReactionContextMenu: openReactionMenu,
});
}, [openReactionMenu, vm]);
useEffect(() => {
vm.setAddReactionButtonActive(menuDisplayed);
}, [menuDisplayed, vm]);
useEffect(() => {
updateReactionsState();
}, [updateReactionsState]);
useEffect(() => {
if (!reactions) return;
reactions.on(RelationsEvent.Add, updateReactionsState);
reactions.on(RelationsEvent.Remove, updateReactionsState);
reactions.on(RelationsEvent.Redaction, updateReactionsState);
return () => {
reactions.off(RelationsEvent.Add, updateReactionsState);
reactions.off(RelationsEvent.Remove, updateReactionsState);
reactions.off(RelationsEvent.Redaction, updateReactionsState);
};
}, [reactions, updateReactionsState]);
useEffect(() => {
const onDecrypted = (): void => {
vm.setActionable(isContentActionable(mxEvent));
};
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
mxEvent.once(MatrixEventEvent.Decrypted, onDecrypted);
}
return () => {
mxEvent.off(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mxEvent, vm]);
const snapshot = useViewModel(vm);
const customReactionImagesEnabled = SettingsStore.getValue("feature_render_reaction_images");
const items = useMemo((): JSX.Element[] | undefined => {
const mappedItems = reactionGroups.map(({ content, events }) => {
// Deduplicate reaction events by sender per Matrix spec.
const deduplicatedEvents = uniqBy(events, (event: MatrixEvent) => event.getSender());
const myReactionEvent = myReactions?.find((reactionEvent) => {
if (reactionEvent.isRedacted()) {
return false;
}
return reactionEvent.getRelation()?.key === content;
});
return (
<ReactionsRowButtonItem
key={content}
content={content}
count={deduplicatedEvents.length}
mxEvent={mxEvent}
reactionEvents={deduplicatedEvents}
myReactionEvent={myReactionEvent}
customReactionImagesEnabled={customReactionImagesEnabled}
disabled={
!roomContext.canReact ||
(myReactionEvent && !myReactionEvent.isRedacted() && !roomContext.canSelfRedact)
}
/>
);
});
if (!mappedItems.length) {
return undefined;
}
return snapshot.showAllButtonVisible ? mappedItems.slice(0, MAX_ITEMS_WHEN_LIMITED) : mappedItems;
}, [
reactionGroups,
myReactions,
mxEvent,
customReactionImagesEnabled,
roomContext.canReact,
roomContext.canSelfRedact,
snapshot.showAllButtonVisible,
]);
useEffect(() => {
vm.setChildren(items);
}, [items, vm]);
if (!snapshot.isVisible || !items?.length) {
return null;
}
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && menuAnchorRect && reactions && roomContext.canReact) {
contextMenu = (
<ContextMenu {...aboveLeftOf(menuAnchorRect)} onFinished={closeReactionMenu} managed={false} focusLock>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeReactionMenu} />
</ContextMenu>
);
}
return (
<>
<ReactionsRowView vm={vm} />
{contextMenu}
</>
);
}

View File

@ -15,7 +15,7 @@ import {
import { _t } from "../../languageHandler";
import { formatList } from "../../utils/FormattingUtils";
import { unicodeToShortcode } from "../../HtmlUtils";
import { REACTION_SHORTCODE_KEY } from "../../components/views/messages/ReactionsRow";
import { REACTION_SHORTCODE_KEY } from "./reactionShortcode";
export interface ReactionsRowButtonTooltipViewModelProps {
/**

View File

@ -16,8 +16,8 @@ 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";
import { REACTION_SHORTCODE_KEY } from "./reactionShortcode";
export interface ReactionsRowButtonViewModelProps {
/**

View File

@ -0,0 +1,174 @@
/*
* 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 MouseEvent, type MouseEventHandler, type ReactNode } from "react";
import {
BaseViewModel,
type ReactionsRowViewSnapshot,
type ReactionsRowViewModel as ReactionsRowViewModelInterface,
} from "@element-hq/web-shared-components";
import { _t } from "../../languageHandler";
export const MAX_ITEMS_WHEN_LIMITED = 8;
export interface ReactionsRowViewModelProps {
/**
* Whether the current event is actionable for reactions.
*/
isActionable: boolean;
/**
* Number of reaction keys with at least one event.
*/
reactionGroupCount: number;
/**
* Whether the current user can add reactions.
*/
canReact: boolean;
/**
* Whether the add-reaction context menu is currently open.
*/
addReactionButtonActive?: boolean;
/**
* Optional callback invoked when the add-reaction button is clicked.
*/
onAddReactionClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Optional callback invoked on add-reaction button context-menu.
*/
onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
/**
* Reaction row children (typically reaction buttons).
*/
children?: ReactNode;
}
interface InternalProps extends ReactionsRowViewModelProps {
showAll: boolean;
}
export class ReactionsRowViewModel
extends BaseViewModel<ReactionsRowViewSnapshot, InternalProps>
implements ReactionsRowViewModelInterface
{
private static readonly computeDerivedSnapshot = (
props: InternalProps,
): Pick<
ReactionsRowViewSnapshot,
"isVisible" | "showAllButtonVisible" | "showAddReactionButton" | "addReactionButtonActive" | "children"
> => ({
isVisible: props.isActionable && props.reactionGroupCount > 0,
showAllButtonVisible: props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !props.showAll,
showAddReactionButton: props.canReact,
addReactionButtonActive: !!props.addReactionButtonActive,
children: props.children,
});
private static readonly computeSnapshot = (props: InternalProps): ReactionsRowViewSnapshot => ({
ariaLabel: _t("common|reactions"),
className: "mx_ReactionsRow",
showAllButtonLabel: _t("action|show_all"),
addReactionButtonLabel: _t("timeline|reactions|add_reaction_prompt"),
addReactionButtonVisible: false,
...ReactionsRowViewModel.computeDerivedSnapshot(props),
});
public constructor(props: ReactionsRowViewModelProps) {
const internalProps: InternalProps = {
...props,
showAll: false,
};
super(internalProps, ReactionsRowViewModel.computeSnapshot(internalProps));
}
public setActionable(isActionable: boolean): void {
this.props = {
...this.props,
isActionable,
};
const isVisible = this.props.isActionable && this.props.reactionGroupCount > 0;
this.snapshot.merge({ isVisible });
}
public setReactionGroupCount(reactionGroupCount: number): void {
this.props = {
...this.props,
reactionGroupCount,
};
const nextIsVisible = this.props.isActionable && this.props.reactionGroupCount > 0;
const nextShowAllButtonVisible =
this.props.reactionGroupCount > MAX_ITEMS_WHEN_LIMITED + 1 && !this.props.showAll;
const updates: Partial<ReactionsRowViewSnapshot> = {};
if (this.snapshot.current.isVisible !== nextIsVisible) {
updates.isVisible = nextIsVisible;
}
if (this.snapshot.current.showAllButtonVisible !== nextShowAllButtonVisible) {
updates.showAllButtonVisible = nextShowAllButtonVisible;
}
if (Object.keys(updates).length > 0) {
this.snapshot.merge(updates);
}
}
public setCanReact(canReact: boolean): void {
this.props = {
...this.props,
canReact,
};
this.snapshot.merge({ showAddReactionButton: canReact });
}
public setAddReactionButtonActive(addReactionButtonActive: boolean): void {
this.props = {
...this.props,
addReactionButtonActive,
};
this.snapshot.merge({ addReactionButtonActive });
}
public setChildren(children?: ReactNode): void {
this.props = {
...this.props,
children,
};
this.snapshot.merge({ children });
}
public setAddReactionHandlers({
onAddReactionClick,
onAddReactionContextMenu,
}: Pick<ReactionsRowViewModelProps, "onAddReactionClick" | "onAddReactionContextMenu">): void {
this.props = {
...this.props,
onAddReactionClick,
onAddReactionContextMenu,
};
}
public onShowAllClick = (): void => {
this.props = {
...this.props,
showAll: true,
};
this.snapshot.merge({ showAllButtonVisible: false });
};
public onAddReactionClick = (event: MouseEvent<HTMLButtonElement>): void => {
this.props.onAddReactionClick?.(event);
};
public onAddReactionContextMenu = (event: MouseEvent<HTMLButtonElement>): void => {
this.props.onAddReactionContextMenu?.(event);
};
}

View File

@ -0,0 +1,10 @@
/*
* 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 { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
export const REACTION_SHORTCODE_KEY = new UnstableValue("shortcode", "com.beeper.reaction.shortcode");

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,106 @@
/*
* 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 MouseEvent } from "react";
import { ReactionsRowViewModel } from "../../../src/viewmodels/message-body/ReactionsRowViewModel";
describe("ReactionsRowViewModel", () => {
const createVm = (
overrides?: Partial<ConstructorParameters<typeof ReactionsRowViewModel>[0]>,
): ReactionsRowViewModel =>
new ReactionsRowViewModel({
isActionable: true,
reactionGroupCount: 10,
canReact: true,
addReactionButtonActive: false,
...overrides,
});
it("computes initial snapshot from props", () => {
const vm = createVm();
const snapshot = vm.getSnapshot();
expect(snapshot.isVisible).toBe(true);
expect(snapshot.showAllButtonVisible).toBe(true);
expect(snapshot.showAddReactionButton).toBe(true);
expect(snapshot.addReactionButtonActive).toBe(false);
expect(snapshot.className).toContain("mx_ReactionsRow");
});
it("hides show-all after onShowAllClick", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
vm.onShowAllClick();
expect(vm.getSnapshot().showAllButtonVisible).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
});
it("updates visibility when reaction group count changes", () => {
const vm = createVm();
vm.setReactionGroupCount(0);
expect(vm.getSnapshot().isVisible).toBe(false);
});
it("updates add-reaction button visibility from canReact", () => {
const vm = createVm();
vm.setCanReact(false);
expect(vm.getSnapshot().showAddReactionButton).toBe(false);
});
it("updates add-reaction active state", () => {
const vm = createVm();
vm.setAddReactionButtonActive(true);
expect(vm.getSnapshot().addReactionButtonActive).toBe(true);
});
it("forwards add-reaction handlers", () => {
const vm = createVm();
const onAddReactionClick = jest.fn();
const onAddReactionContextMenu = jest.fn();
vm.setAddReactionHandlers({
onAddReactionClick,
onAddReactionContextMenu,
});
const clickEvent = {
currentTarget: document.createElement("button"),
} as unknown as MouseEvent<HTMLButtonElement>;
vm.onAddReactionClick(clickEvent);
vm.onAddReactionContextMenu(clickEvent);
expect(onAddReactionClick).toHaveBeenCalledWith(clickEvent);
expect(onAddReactionContextMenu).toHaveBeenCalledWith(clickEvent);
});
it("emits only for setters that always merge when values are unchanged", () => {
const vm = createVm();
const previousSnapshot = vm.getSnapshot();
const listener = jest.fn();
vm.subscribe(listener);
vm.setCanReact(true);
vm.setReactionGroupCount(10);
vm.setActionable(true);
vm.setAddReactionButtonActive(false);
// `setReactionGroupCount` is optimized and skips emit for unchanged derived values.
// The other setters always merge and therefore emit.
expect(listener).toHaveBeenCalledTimes(3);
expect(vm.getSnapshot()).toEqual(previousSnapshot);
});
});

View File

@ -21,6 +21,7 @@ export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/ReactionsRowButtonTooltip";
export * from "./message-body/ReactionsRowButton";
export * from "./message-body/ReactionRow";
export * from "./message-body/TimelineSeparator/";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";

View File

@ -0,0 +1,70 @@
/*
* 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.
*/
.reactionsRow {
color: var(--cpd-color-text-primary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--cpd-space-1x);
}
.showAllButton {
all: unset;
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-xs);
line-height: 20px;
cursor: pointer;
}
.showAllButton:hover,
.showAllButton:focus-visible {
color: var(--cpd-color-text-primary);
}
.addReactionButton {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: var(--cpd-space-1x);
margin-inline: var(--cpd-space-1x);
border-radius: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
visibility: hidden;
cursor: pointer;
}
.reactionsRow:hover .addReactionButton {
visibility: visible;
}
.addReactionButtonVisible {
visibility: visible;
}
.addReactionButtonActive {
visibility: visible;
}
.addReactionButtonDisabled {
cursor: not-allowed;
opacity: 0.6;
}
.addReactionButton:hover,
.addReactionButton:focus-visible,
.addReactionButtonActive {
color: var(--cpd-color-icon-primary);
}
.addReactionButton svg {
width: 16px;
height: 16px;
}

View File

@ -0,0 +1,119 @@
/*
* 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 { ReactionsRowButtonView } from "../ReactionsRowButton";
import { useMockedViewModel } from "../../viewmodel";
import { ReactionsRowView, type ReactionsRowViewActions, type ReactionsRowViewSnapshot } from "./ReactionsRowView";
interface MockReactionButtonProps {
content: string;
count: number;
isSelected?: boolean;
}
const MockReactionButton = ({ content, count, isSelected }: Readonly<MockReactionButtonProps>): JSX.Element => {
const tooltipVm = useMockedViewModel(
{
formattedSenders: "Alice and Bob",
caption: undefined,
tooltipOpen: false,
},
{},
);
const vm = useMockedViewModel(
{
content,
count,
"isSelected": !!isSelected,
"isDisabled": false,
tooltipVm,
"aria-label": `${count} reactions for ${content}`,
},
{
onClick: fn(),
},
);
return <ReactionsRowButtonView vm={vm} />;
};
const DefaultReactionButtons = (): JSX.Element => (
<>
<MockReactionButton content="👍" count={4} isSelected />
<MockReactionButton content="🎉" count={2} />
<MockReactionButton content="👀" count={1} />
</>
);
type WrapperProps = ReactionsRowViewSnapshot & Partial<ReactionsRowViewActions>;
const ReactionsRowViewWrapper = ({
onShowAllClick,
onAddReactionClick,
onAddReactionContextMenu,
...snapshotProps
}: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onShowAllClick: onShowAllClick ?? fn(),
onAddReactionClick: onAddReactionClick ?? fn(),
onAddReactionContextMenu: onAddReactionContextMenu ?? fn(),
});
return <ReactionsRowView vm={vm} />;
};
const meta = {
title: "MessageBody/ReactionsRow",
component: ReactionsRowViewWrapper,
tags: ["autodocs"],
args: {
ariaLabel: "Reactions",
isVisible: true,
children: <DefaultReactionButtons />,
showAllButtonVisible: false,
showAllButtonLabel: "Show all",
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
addReactionButtonActive: false,
addReactionButtonDisabled: false,
},
} satisfies Meta<typeof ReactionsRowViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithShowAllButton: Story = {
args: {
showAllButtonVisible: true,
},
};
export const AddReactionButtonActive: Story = {
args: {
addReactionButtonActive: true,
},
};
export const AddReactionButtonHiddenUntilHover: Story = {
args: {
addReactionButtonVisible: false,
},
};
export const Hidden: Story = {
args: {
isVisible: false,
},
};

View File

@ -0,0 +1,90 @@
/*
* 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, screen } from "@test-utils";
import React, { type MouseEventHandler } from "react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../viewmodel";
import {
ReactionsRowView,
type ReactionsRowViewActions,
type ReactionsRowViewModel,
type ReactionsRowViewSnapshot,
} from "./ReactionsRowView";
import * as stories from "./ReactionsRow.stories";
const { Default, WithShowAllButton, Hidden } = composeStories(stories);
describe("ReactionsRowView", () => {
it("renders the default reactions row", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the row with a show-all button", () => {
const { container } = render(<WithShowAllButton />);
expect(container).toMatchSnapshot();
});
it("does not render the row when hidden", () => {
render(<Hidden />);
expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
});
it("invokes show-all and add-reaction actions", async () => {
const user = userEvent.setup();
const onShowAllClick = vi.fn();
const onAddReactionClick = vi.fn();
const onAddReactionContextMenu = vi.fn();
class TestReactionsRowViewModel
extends MockViewModel<ReactionsRowViewSnapshot>
implements ReactionsRowViewActions
{
public onShowAllClick?: () => void;
public onAddReactionClick?: MouseEventHandler<HTMLButtonElement>;
public onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: ReactionsRowViewSnapshot, actions: ReactionsRowViewActions) {
super(snapshot);
Object.assign(this, actions);
}
}
const vm = new TestReactionsRowViewModel(
{
ariaLabel: "Reactions",
isVisible: true,
showAllButtonVisible: true,
showAllButtonLabel: "Show all",
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
children: <span>👍</span>,
},
{
onShowAllClick,
onAddReactionClick,
onAddReactionContextMenu,
},
) as ReactionsRowViewModel;
render(<ReactionsRowView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Show all" }));
await user.click(screen.getByRole("button", { name: "Add reaction" }));
await user.pointer({ target: screen.getByRole("button", { name: "Add reaction" }), keys: "[MouseRight]" });
expect(onShowAllClick).toHaveBeenCalledTimes(1);
expect(onAddReactionClick).toHaveBeenCalledTimes(1);
expect(onAddReactionContextMenu).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,144 @@
/*
* 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 MouseEventHandler, type ReactNode } from "react";
import classNames from "classnames";
import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./ReactionsRow.module.css";
export interface ReactionsRowViewSnapshot {
/**
* Toolbar label announced by assistive technologies.
*/
ariaLabel: string;
/**
* Controls whether the row should render at all.
*/
isVisible: boolean;
/**
* Reaction button elements to render in the row.
*/
children?: ReactNode;
/**
* Optional CSS className for the row container.
*/
className?: string;
/**
* Whether to render the "show all" button.
*/
showAllButtonVisible?: boolean;
/**
* Label shown for the "show all" button.
*/
showAllButtonLabel?: string;
/**
* Whether to render the add-reaction button.
*/
showAddReactionButton?: boolean;
/**
* Accessible label for the add-reaction button.
*/
addReactionButtonLabel: string;
/**
* Force the add-reaction button to be visible.
*/
addReactionButtonVisible?: boolean;
/**
* Marks the add-reaction button as active.
*/
addReactionButtonActive?: boolean;
/**
* Disables the add-reaction button.
*/
addReactionButtonDisabled?: boolean;
}
export interface ReactionsRowViewActions {
/**
* Invoked when the user clicks the "show all" button.
*/
onShowAllClick?: () => void;
/**
* Invoked when the user clicks the add-reaction button.
*/
onAddReactionClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked on right-click/context-menu for the add-reaction button.
*/
onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
}
export type ReactionsRowViewModel = ViewModel<ReactionsRowViewSnapshot, ReactionsRowViewActions>;
interface ReactionsRowViewProps {
vm: ReactionsRowViewModel;
}
export function ReactionsRowView({ vm }: Readonly<ReactionsRowViewProps>): JSX.Element {
const {
ariaLabel,
isVisible,
children,
className,
showAllButtonVisible,
showAllButtonLabel,
showAddReactionButton,
addReactionButtonLabel,
addReactionButtonVisible,
addReactionButtonActive,
addReactionButtonDisabled,
} = useViewModel(vm);
if (!isVisible) {
return <></>;
}
const addReactionButtonClasses = classNames(styles.addReactionButton, {
[styles.addReactionButtonVisible]: addReactionButtonVisible,
[styles.addReactionButtonActive]: addReactionButtonActive,
[styles.addReactionButtonDisabled]: addReactionButtonDisabled,
});
const onAddReactionContextMenu: MouseEventHandler<HTMLButtonElement> | undefined = vm.onAddReactionContextMenu
? (event): void => {
event.preventDefault();
vm.onAddReactionContextMenu?.(event);
}
: undefined;
const addReactionButton = (
<button
type="button"
className={addReactionButtonClasses}
aria-label={addReactionButtonLabel}
disabled={addReactionButtonDisabled}
onClick={vm.onAddReactionClick}
onContextMenu={onAddReactionContextMenu}
>
<ReactionAddIcon />
</button>
);
return (
<div className={classNames(className, styles.reactionsRow)} role="toolbar" aria-label={ariaLabel}>
{children}
{showAllButtonVisible && (
<button type="button" className={styles.showAllButton} onClick={vm.onShowAllClick}>
{showAllButtonLabel}
</button>
)}
{showAddReactionButton && (
<Tooltip description={addReactionButtonLabel} placement="right">
{addReactionButton}
</Tooltip>
)}
</div>
);
}

View File

@ -0,0 +1,183 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ReactionsRowView > renders the default reactions row 1`] = `
<div>
<div
aria-label="Reactions"
class="reactionsRow"
role="toolbar"
>
<button
aria-label="4 reactions for 👍"
class="reactionsRowButton reactionsRowButtonSelected"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👍
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
4
</span>
</button>
<button
aria-label="2 reactions for 🎉"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
🎉
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
2
</span>
</button>
<button
aria-label="1 reactions for 👀"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👀
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
1
</span>
</button>
<button
aria-label="Add reaction"
class="addReactionButton addReactionButtonVisible"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ReactionsRowView > renders the row with a show-all button 1`] = `
<div>
<div
aria-label="Reactions"
class="reactionsRow"
role="toolbar"
>
<button
aria-label="4 reactions for 👍"
class="reactionsRowButton reactionsRowButtonSelected"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👍
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
4
</span>
</button>
<button
aria-label="2 reactions for 🎉"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
🎉
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
2
</span>
</button>
<button
aria-label="1 reactions for 👀"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👀
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
1
</span>
</button>
<button
class="showAllButton"
type="button"
>
Show all
</button>
<button
aria-label="Add reaction"
class="addReactionButton addReactionButtonVisible"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
</div>
</div>
`;

View File

@ -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 {
ReactionsRowView,
type ReactionsRowViewSnapshot,
type ReactionsRowViewModel,
type ReactionsRowViewActions,
} from "./ReactionsRowView";

View File

@ -8,6 +8,7 @@
.reactionsRowButton {
display: inline-flex;
all: unset;
cursor: pointer;
line-height: var(--cpd-font-size-heading-sm);
padding: 1px var(--cpd-space-1-5x);
border: 1px solid var(--cpd-color-gray-400);