Refactor ThreadSummary to MVVM (#33603)

* Refactor ThreadSummary to MVVM

* Stories Snapshot images

* Add ThreadSummary ViewModel coverage

* Fix ThreadSummary preview avatar rendering

* Remove ThreadSummary classnames helper

* Fix Prettier

* Match ThreadMessagePreview typography

* Move folder to correct path and fix storybook path

* Catch ThreadSummary preview refresh errors

* Update snapshot images + fix prettier

* Fix ThreadSummary classNames import

* Update Images

* Remove wrong path
This commit is contained in:
Zack
2026-05-28 08:57:55 +00:00
committed by GitHub
parent 34c388f760
commit 3bb9cb2234
20 changed files with 2485 additions and 262 deletions
@@ -20,6 +20,7 @@ import React, {
type MouseEvent,
type ReactNode,
} from "react";
import classNames from "classnames";
import {
type EventStatus,
EventType,
@@ -46,6 +47,8 @@ import {
PinnedMessageBadge,
ReactionsRowButtonView,
ReactionsRowView,
ThreadMessagePreviewView,
ThreadSummaryView,
TileErrorView,
useViewModel,
} from "@element-hq/web-shared-components";
@@ -80,7 +83,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../../PosthogTrackers";
import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory";
import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from "./ReadReceiptGroup";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
@@ -91,6 +93,7 @@ import { EventPreview } from "./EventPreview";
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
import SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
import {
@@ -119,6 +122,10 @@ import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
import {
ThreadMessagePreviewViewModel,
ThreadSummaryViewModel,
} from "../../../viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx";
import { ReactionsRowButtonViewModel } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowButtonViewModel";
import {
MAX_ITEMS_WHEN_LIMITED,
@@ -535,7 +542,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
<div className="mx_ThreadPanel_replies">
<ThreadsIcon />
<span className="mx_ThreadPanel_replies_amount">{threadState.thread.length}</span>
<ThreadMessagePreview thread={threadState.thread} />
<ThreadMessagePreviewWrapper thread={threadState.thread} />
</div>
);
}
@@ -543,7 +550,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode {
if (threadState.shouldShowThreadSummary && threadState.thread) {
return (
<ThreadSummary mxEvent={this.props.mxEvent} thread={threadState.thread} data-testid="thread-summary" />
<ThreadSummaryWrapper
mxEvent={this.props.mxEvent}
thread={threadState.thread}
data-testid="thread-summary"
/>
);
}
@@ -1552,6 +1563,100 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele
);
}
interface ThreadMessagePreviewWrapperProps {
thread: Thread;
showDisplayName?: boolean;
}
function ThreadMessagePreviewWrapper({
thread,
showDisplayName = false,
}: Readonly<ThreadMessagePreviewWrapperProps>): JSX.Element {
const cli = useMatrixClientContext();
const { room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
"room",
"timelineRenderingType",
"lowBandwidth",
);
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
const vm = useCreateAutoDisposedViewModel(
() =>
new ThreadMessagePreviewViewModel({
cli,
thread,
room,
timelineRenderingType,
lowBandwidth,
useOnlyCurrentProfiles,
showDisplayName,
avatarClassName: "mx_BaseAvatar",
}),
);
useEffect(() => {
vm.setClient(cli);
vm.setThread(thread);
vm.setRoom(room);
vm.setTimelineRenderingType(timelineRenderingType);
vm.setLowBandwidth(lowBandwidth);
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
vm.setShowDisplayName(showDisplayName);
}, [vm, cli, thread, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles, showDisplayName]);
return <ThreadMessagePreviewView vm={vm} />;
}
interface ThreadSummaryWrapperProps extends Omit<React.ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> {
mxEvent: MatrixEvent;
thread: Thread;
}
function ThreadSummaryWrapper({
mxEvent,
thread,
className,
...props
}: Readonly<ThreadSummaryWrapperProps>): JSX.Element {
const cli = useMatrixClientContext();
const { isCard } = useContext(CardContext);
const { narrow, room, timelineRenderingType, lowBandwidth } = useScopedRoomContext(
"narrow",
"room",
"timelineRenderingType",
"lowBandwidth",
);
const useOnlyCurrentProfiles = useSettingValue("useOnlyCurrentProfiles");
const vm = useCreateAutoDisposedViewModel(
() =>
new ThreadSummaryViewModel({
cli,
mxEvent,
thread,
narrow,
isCard,
room,
timelineRenderingType,
lowBandwidth,
useOnlyCurrentProfiles,
avatarClassName: "mx_BaseAvatar",
}),
);
useEffect(() => {
vm.setClient(cli);
vm.setRootEvent(mxEvent);
vm.setThread(thread);
vm.setNarrow(narrow);
vm.setIsCard(isCard);
vm.setRoom(room);
vm.setTimelineRenderingType(timelineRenderingType);
vm.setLowBandwidth(lowBandwidth);
vm.setUseOnlyCurrentProfiles(useOnlyCurrentProfiles);
}, [vm, cli, mxEvent, thread, narrow, isCard, room, timelineRenderingType, lowBandwidth, useOnlyCurrentProfiles]);
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
}
interface ReactionsRowButtonItemProps {
mxEvent: MatrixEvent;
content: string;
@@ -1,111 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 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, { useContext } from "react";
import { type Thread, ThreadEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { IndicatorIcon } from "@vector-im/compound-web";
import { ThreadsSolidIcon, ChevronRightIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../languageHandler";
import { CardContext } from "../right_panel/context";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import PosthogTrackers from "../../../PosthogTrackers";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import MemberAvatar from "../avatars/MemberAvatar";
import { Action } from "../../../dispatcher/actions";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications";
import { notificationLevelToIndicator } from "../../../utils/notifications";
import { EventPreviewTile, useEventPreview } from "./EventPreview.tsx";
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
interface IProps {
mxEvent: MatrixEvent;
thread: Thread;
}
const ThreadSummary: React.FC<IProps> = ({ mxEvent, thread, ...props }) => {
const roomContext = useScopedRoomContext("narrow");
const cardContext = useContext(CardContext);
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
const { level } = useUnreadNotifications(thread.room, thread.id);
if (!count) return null; // We don't want to show a thread summary if the thread doesn't have replies yet
let countSection: string | number = count;
if (!roomContext.narrow) {
countSection = _t("threads|count_of_reply", { count });
}
return (
<AccessibleButton
{...props}
className="mx_ThreadSummary"
onClick={(ev: ButtonEvent) => {
defaultDispatcher.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: mxEvent,
push: cardContext.isCard,
});
PosthogTrackers.trackInteraction("WebRoomTimelineThreadSummaryButton", ev);
}}
aria-label={_t("threads|open_thread")}
>
<IndicatorIcon size="24px" indicator={notificationLevelToIndicator(level)}>
<ThreadsSolidIcon />
</IndicatorIcon>
<span className="mx_ThreadSummary_replies_amount">{countSection}</span>
<ThreadMessagePreview thread={thread} showDisplayname={!roomContext.narrow} />
<div className="mx_ThreadSummary_chevron">
<ChevronRightIcon />
</div>
</AccessibleButton>
);
};
interface IPreviewProps {
thread: Thread;
showDisplayname?: boolean;
}
export const ThreadMessagePreview: React.FC<IPreviewProps> = ({ thread, showDisplayname = false }) => {
const lastReply = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.replyToEvent) ?? undefined;
const preview = useEventPreview(lastReply);
if (!preview || !lastReply) {
return null;
}
return (
<>
<MemberAvatar
member={lastReply.sender}
fallbackUserId={lastReply.getSender()}
size="24px"
className="mx_ThreadSummary_avatar"
/>
{showDisplayname && (
<div className="mx_ThreadSummary_sender">{lastReply.sender?.name ?? lastReply.getSender()}</div>
)}
{lastReply.isDecryptionFailure() ? (
<div
className="mx_ThreadSummary_content mx_DecryptionFailureBody"
title={_t("timeline|decryption_failure|unable_to_decrypt")}
>
{_t("timeline|decryption_failure|unable_to_decrypt")}
</div>
) : (
<EventPreviewTile preview={preview} className="mx_ThreadSummary_content" />
)}
</>
);
};
export default ThreadSummary;