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
21 changed files with 1175 additions and 290 deletions
@@ -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>
);
}
}
@@ -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}
</>
);
}