diff --git a/apps/web/src/components/views/messages/ReactionsRow.tsx b/apps/web/src/components/views/messages/ReactionsRow.tsx deleted file mode 100644 index ef21ad9e82..0000000000 --- a/apps/web/src/components/views/messages/ReactionsRow.tsx +++ /dev/null @@ -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 = ({ mxEvent, reactions }) => { - const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - - let contextMenu: JSX.Element | undefined; - if (menuDisplayed && button.current) { - const buttonRect = button.current.getBoundingClientRect(); - contextMenu = ( - - - - ); - } - - return ( - - { - e.preventDefault(); - openMenu(); - }} - isExpanded={menuDisplayed} - ref={button} - > - - - - {contextMenu} - - ); -}; - -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; - // 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 { - public static contextType = RoomContext; - declare public context: React.ContextType; - - public constructor(props: IProps, context: React.ContextType) { - 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 ( - - ); - }) - .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 = ( - - {_t("action|show_all")} - - ); - } - - let addReactionButton: JSX.Element | undefined; - if (this.context.canReact) { - addReactionButton = ; - } - - return ( -
- {items} - {showAllButton} - {addReactionButton} -
- ); - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 47741d3abb..a6b7b7c326 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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 let reactionsRow: JSX.Element | undefined; if (!isRedacted) { reactionsRow = ( - ); } + +interface ReactionsRowButtonItemProps { + mxEvent: MatrixEvent; + content: string; + count: number; + reactionEvents: MatrixEvent[]; + myReactionEvent?: MatrixEvent; + disabled?: boolean; + customReactionImagesEnabled?: boolean; +} + +function ReactionsRowButtonItem(props: Readonly): 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 ; +} + +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): JSX.Element | null { + const roomContext = useContext(RoomContext); + const userId = roomContext.room?.client.getUserId() ?? undefined; + const [reactionGroups, setReactionGroups] = useState(() => getReactionGroups(reactions)); + const [myReactions, setMyReactions] = useState(() => getMyReactions(reactions, userId)); + const [menuDisplayed, setMenuDisplayed] = useState(false); + const [menuAnchorRect, setMenuAnchorRect] = useState(null); + + const vm = useCreateAutoDisposedViewModel( + () => + new ReactionsRowViewModel({ + isActionable: isContentActionable(mxEvent), + reactionGroupCount: reactionGroups.length, + canReact: roomContext.canReact, + addReactionButtonActive: false, + }), + ); + + const openReactionMenu = useCallback((event: React.MouseEvent): 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 ( + + ); + }); + + 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 = ( + + + + ); + } + + return ( + <> + + {contextMenu} + + ); +} diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts index baa7b77855..ab3ce8204d 100644 --- a/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts +++ b/apps/web/src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel.ts @@ -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 { /** diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts index 6435a38d6b..6f86c71104 100644 --- a/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts +++ b/apps/web/src/viewmodels/message-body/ReactionsRowButtonViewModel.ts @@ -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 { /** diff --git a/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts b/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts new file mode 100644 index 0000000000..48443b2ea2 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ReactionsRowViewModel.ts @@ -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; + /** + * Optional callback invoked on add-reaction button context-menu. + */ + onAddReactionContextMenu?: MouseEventHandler; + /** + * Reaction row children (typically reaction buttons). + */ + children?: ReactNode; +} + +interface InternalProps extends ReactionsRowViewModelProps { + showAll: boolean; +} + +export class ReactionsRowViewModel + extends BaseViewModel + 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 = {}; + + 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): 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): void => { + this.props.onAddReactionClick?.(event); + }; + + public onAddReactionContextMenu = (event: MouseEvent): void => { + this.props.onAddReactionContextMenu?.(event); + }; +} diff --git a/apps/web/src/viewmodels/message-body/reactionShortcode.ts b/apps/web/src/viewmodels/message-body/reactionShortcode.ts new file mode 100644 index 0000000000..ad90537506 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/reactionShortcode.ts @@ -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"); 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 19d065b3a5..a84fc3f724 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/ReactionsRowViewModel-test.tsx b/apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx new file mode 100644 index 0000000000..46c57d2b21 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/ReactionsRowViewModel-test.tsx @@ -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[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; + 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); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png new file mode 100644 index 0000000000..5588f34e92 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-active-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-hidden-until-hover-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-hidden-until-hover-auto.png new file mode 100644 index 0000000000..907ed9a98b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/add-reaction-button-hidden-until-hover-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png new file mode 100644 index 0000000000..5ce5943eef Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..9f58a62407 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/with-show-all-button-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/with-show-all-button-auto.png new file mode 100644 index 0000000000..e03cf02af3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/ReactionRow/ReactionsRow.stories.tsx/with-show-all-button-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index fffbce1c75..2f1a1a04f5 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.module.css b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.module.css new file mode 100644 index 0000000000..5e469b1be5 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.module.css @@ -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; +} diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.stories.tsx b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.stories.tsx new file mode 100644 index 0000000000..a39ebc2373 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.stories.tsx @@ -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): 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 ; +}; + +const DefaultReactionButtons = (): JSX.Element => ( + <> + + + + +); + +type WrapperProps = ReactionsRowViewSnapshot & Partial; + +const ReactionsRowViewWrapper = ({ + onShowAllClick, + onAddReactionClick, + onAddReactionContextMenu, + ...snapshotProps +}: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, { + onShowAllClick: onShowAllClick ?? fn(), + onAddReactionClick: onAddReactionClick ?? fn(), + onAddReactionContextMenu: onAddReactionContextMenu ?? fn(), + }); + + return ; +}; + +const meta = { + title: "MessageBody/ReactionsRow", + component: ReactionsRowViewWrapper, + tags: ["autodocs"], + args: { + ariaLabel: "Reactions", + isVisible: true, + children: , + showAllButtonVisible: false, + showAllButtonLabel: "Show all", + showAddReactionButton: true, + addReactionButtonLabel: "Add reaction", + addReactionButtonVisible: true, + addReactionButtonActive: false, + addReactionButtonDisabled: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx new file mode 100644 index 0000000000..a6f28b9370 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRow.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("renders the row with a show-all button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("does not render the row when hidden", () => { + render(); + 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 + implements ReactionsRowViewActions + { + public onShowAllClick?: () => void; + public onAddReactionClick?: MouseEventHandler; + public onAddReactionContextMenu?: MouseEventHandler; + + 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: 👍, + }, + { + onShowAllClick, + onAddReactionClick, + onAddReactionContextMenu, + }, + ) as ReactionsRowViewModel; + + render(); + + 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); + }); +}); diff --git a/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx b/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx new file mode 100644 index 0000000000..fffd9d6c1b --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/ReactionsRowView.tsx @@ -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; + /** + * Invoked on right-click/context-menu for the add-reaction button. + */ + onAddReactionContextMenu?: MouseEventHandler; +} + +export type ReactionsRowViewModel = ViewModel; + +interface ReactionsRowViewProps { + vm: ReactionsRowViewModel; +} + +export function ReactionsRowView({ vm }: Readonly): 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 | undefined = vm.onAddReactionContextMenu + ? (event): void => { + event.preventDefault(); + vm.onAddReactionContextMenu?.(event); + } + : undefined; + + const addReactionButton = ( + + ); + + return ( +
    + {children} + {showAllButtonVisible && ( + + )} + {showAddReactionButton && ( + + {addReactionButton} + + )} +
    + ); +} diff --git a/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap b/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap new file mode 100644 index 0000000000..404aa6dd05 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/__snapshots__/ReactionsRow.test.tsx.snap @@ -0,0 +1,183 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReactionsRowView > renders the default reactions row 1`] = ` +
    + +
    +`; + +exports[`ReactionsRowView > renders the row with a show-all button 1`] = ` +
    + +
    +`; diff --git a/packages/shared-components/src/message-body/ReactionRow/index.tsx b/packages/shared-components/src/message-body/ReactionRow/index.tsx new file mode 100644 index 0000000000..4925bf02a0 --- /dev/null +++ b/packages/shared-components/src/message-body/ReactionRow/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 { + ReactionsRowView, + type ReactionsRowViewSnapshot, + type ReactionsRowViewModel, + type ReactionsRowViewActions, +} from "./ReactionsRowView"; diff --git a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css index 3d1af1a83d..ab4360759b 100644 --- a/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css +++ b/packages/shared-components/src/message-body/ReactionsRowButton/ReactionsRowButton.module.css @@ -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);