Refactor EventTile using the MVVM pattern - #2 (#33489)

* Improve coverage

* Add view model unit tests to improve coverage

* Extract initial pure EventTile derived state helpers

* Extract EventTile derived state helpers

* Extract EventTile class state derivation

* Extract EventTile line class derivation

* Extract EventTile sender profile state

* Extract EventTile avatar member selection

* Extract EventTile avatar clickability state

* Extract EventTile sender profile mode

* Extract EventTile action bar visibility

* Extract EventTile timestamp visibility

* Extract EventTile timestamp selection

* Extract EventTile timestamp display state

* Extract ReplyChain timestamp visibility

* Extract EventTile footer display state

* Fix sonar issue
This commit is contained in:
rbondesson
2026-05-18 05:36:13 +00:00
committed by GitHub
parent 1922266ba7
commit 7fe8cdafe0
7 changed files with 1011 additions and 153 deletions
@@ -65,6 +65,18 @@ describe("AudioPlayerViewModel", () => {
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
});
it("does not stop propagation for unhandled key down events", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
const event = new KeyboardEvent("keydown", { key: "a" });
const stopPropagationSpy = jest.spyOn(event, "stopPropagation");
vm.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLDivElement>);
expect(stopPropagationSpy).not.toHaveBeenCalled();
expect(playback.toggle).not.toHaveBeenCalled();
expect(playback.skipTo).not.toHaveBeenCalled();
});
it("should update snapshot when setProps is called with new mediaName", () => {
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
expect(vm.getSnapshot().mediaName).toBe("oldName");
@@ -119,4 +119,68 @@ describe("MessageTimestampViewModel", () => {
href: "https://example.test",
});
});
it("updates the timestamp and received timestamp", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
vm.setTimestamp(nowDate.getTime() + HOUR_MS);
vm.setReceivedTimestamp(nowDate.getTime() + DAY_MS);
expect(vm.getSnapshot()).toMatchObject({
ts: "09:09",
tsSentAt: "Fri, Dec 17, 2021, 09:09:00",
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
});
});
it("updates display options", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
vm.setDisplayOptions({
showTwelveHour: true,
showSeconds: true,
});
expect(vm.getSnapshot()).toMatchObject({
ts: "8:09:00 AM",
tsSentAt: "Fri, Dec 17, 2021, 8:09:00 AM",
});
});
it("updates tooltip, href, and handlers", () => {
const onClick = jest.fn();
const onContextMenu = jest.fn();
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
});
vm.setTooltipInhibited(true);
vm.setHref("https://example.test/event");
vm.setHandlers({ onClick, onContextMenu });
expect(vm.getSnapshot()).toMatchObject({
inhibitTooltip: true,
href: "https://example.test/event",
});
expect(vm.onClick).toBe(onClick);
expect(vm.onContextMenu).toBe(onContextMenu);
});
it("does not emit an update when props are unchanged", () => {
const vm = new MessageTimestampViewModel({
ts: nowDate.getTime(),
href: "https://example.test/event",
});
const listener = jest.fn();
vm.subscribe(listener);
vm.setTimestamp(nowDate.getTime());
vm.setHref("https://example.test/event");
expect(listener).not.toHaveBeenCalled();
});
});
@@ -16,6 +16,9 @@ import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
jest.mock("../../../src/dispatcher/dispatcher");
jest.mock("../../../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(() => ({ srcHttp: "https://example.org/_matrix/media/reaction.png" })),
}));
describe("ReactionsRowButtonViewModel", () => {
let client: MatrixClient;
@@ -91,6 +94,35 @@ describe("ReactionsRowButtonViewModel", () => {
expect(getAriaLabel(vm)).toContain("reacted with 👍");
});
it("falls back when no room is available", () => {
jest.spyOn(client, "getRoom").mockReturnValue(null);
const vm = new ReactionsRowButtonViewModel(createProps());
expect(getAriaLabel(vm)).toBeUndefined();
expect(vm.getSnapshot().content).toBe("👍");
expect(vm.getSnapshot().count).toBe(2);
});
it("renders custom reaction images with shortcode labels when enabled", () => {
const reactionEvent = createReactionEvent("@alice:example.org", "mxc://example.org/reaction");
reactionEvent.getContent()["shortcode"] = "party";
const vm = new ReactionsRowButtonViewModel(
createProps({
content: "mxc://example.org/reaction",
reactionEvents: [reactionEvent],
customReactionImagesEnabled: true,
}),
);
expect(vm.getSnapshot()).toMatchObject({
imageSrc: "https://example.org/_matrix/media/reaction.png",
imageAlt: "party",
});
expect(getAriaLabel(vm)).toContain("reacted with party");
});
it("updates selected state with myReactionEvent without touching tooltip props", () => {
const vm = new ReactionsRowButtonViewModel(createProps());
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
@@ -0,0 +1,57 @@
/*
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
import { ThreadListActionBarViewModel } from "../../../src/viewmodels/room/ThreadListActionBarViewModel";
describe("ThreadListActionBarViewModel", () => {
it("builds the thread-list action bar snapshot", () => {
const vm = new ThreadListActionBarViewModel({});
expect(vm.getSnapshot()).toMatchObject({
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
presentation: "icon",
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
});
});
it("forwards actions to the configured handlers", () => {
const onViewInRoomClick = jest.fn();
const onCopyLinkClick = jest.fn();
const vm = new ThreadListActionBarViewModel({
onViewInRoomClick,
onCopyLinkClick,
});
const anchor = document.createElement("button");
vm.onViewInRoomClick(anchor);
vm.onCopyLinkClick(anchor);
expect(onViewInRoomClick).toHaveBeenCalledWith(anchor);
expect(onCopyLinkClick).toHaveBeenCalledWith(anchor);
});
it("uses updated handlers after setProps", () => {
const initialOnViewInRoomClick = jest.fn();
const nextOnViewInRoomClick = jest.fn();
const vm = new ThreadListActionBarViewModel({
onViewInRoomClick: initialOnViewInRoomClick,
});
const anchor = document.createElement("button");
vm.setProps({ onViewInRoomClick: nextOnViewInRoomClick });
vm.onViewInRoomClick(anchor);
expect(initialOnViewInRoomClick).not.toHaveBeenCalled();
expect(nextOnViewInRoomClick).toHaveBeenCalledWith(anchor);
});
});