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
@@ -19,6 +19,8 @@ import {
MsgType,
NotificationCountType,
PendingEventOrdering,
RelationType,
type Relations,
Room,
TweakName,
} from "matrix-js-sdk/src/matrix";
@@ -101,6 +103,41 @@ function makeThreadReplyEvent(roomId: string): MatrixEvent {
});
}
function makeReactionEvent(roomId: string, targetEventId: string, sender: string, key: string): MatrixEvent {
return mkEvent({
event: true,
type: EventType.Reaction,
room: roomId,
user: sender,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: targetEventId,
key,
},
},
});
}
function makeRelations(
reactionsByKey: Map<string, MatrixEvent[]>,
reactionsBySender: Record<string, MatrixEvent[]> = {},
): Relations {
return {
getSortedAnnotationsByKey: () =>
[...reactionsByKey.entries()].map(([key, events]) => [key, new Set(events)] as [string, Set<MatrixEvent>]),
getAnnotationsBySender: () =>
Object.fromEntries(
Object.entries(reactionsBySender).map(([sender, events]) => [
sender,
new Map(events.map((ev) => [ev.getId(), ev])),
]),
),
on: jest.fn(),
off: jest.fn(),
} as unknown as Relations;
}
describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
let mxEvent: MatrixEvent;
@@ -259,6 +296,18 @@ describe("EventTile", () => {
expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens");
});
it("sets aria-live to off when the send status is undefined", () => {
const { container } = getComponent();
expect(getTile(container)).toHaveAttribute("aria-live", "off");
});
it("does not set aria-live when the send status is explicitly null", () => {
const { container } = getComponent({ eventSendStatus: null as unknown as EventStatus });
expect(getTile(container)).not.toHaveAttribute("aria-live");
});
});
describe("rendering root attributes", () => {
@@ -507,6 +556,68 @@ describe("EventTile", () => {
expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar"));
});
it("keeps sender and avatar when only the layout prop is set to bubble", () => {
const { container } = getComponent({ layout: Layout.Bubble });
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
});
it("hides the sender but keeps the info-message avatar for room create events", () => {
const createEvent = mkEvent({
event: true,
type: EventType.RoomCreate,
room: room.roomId,
user: "@alice:example.org",
content: { creator: "@alice:example.org", room_version: "1" },
});
const { container } = getComponent({ mxEvent: createEvent }, TimelineRenderingType.Room, {
showHiddenEvents: true,
});
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
});
it("renders the notification avatar independently from the sender details", () => {
const { container } = getComponent({}, TimelineRenderingType.Notification);
const details = container.querySelector<HTMLElement>(".mx_EventTile_details");
const avatar = container.querySelector<HTMLElement>(".mx_EventTile_avatar");
expect(details).not.toBeNull();
expect(avatar).not.toBeNull();
expect(details).not.toContainElement(avatar);
});
});
describe("continuation rendering", () => {
it.each([TimelineRenderingType.Room, TimelineRenderingType.Search, TimelineRenderingType.Thread])(
"keeps continuation styling in %s timelines",
(renderingType) => {
const { container } = getComponent({ continuation: true }, renderingType);
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
},
);
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
"drops continuation styling in %s timelines when not using bubble layout",
(renderingType) => {
const { container } = getComponent({ continuation: true }, renderingType);
expect(getTile(container)).not.toHaveClass("mx_EventTile_continuation");
},
);
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
"keeps continuation styling in %s timelines when using bubble layout",
(renderingType) => {
const { container } = getComponent({ continuation: true, layout: Layout.Bubble }, renderingType);
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
},
);
});
describe("read receipt option", () => {
@@ -614,6 +725,55 @@ describe("EventTile", () => {
expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull();
expect(screen.getByText("Pinned message")).toBeInTheDocument();
});
it("renders the IRC footer inside the event line", () => {
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
const { container } = getComponent({ layout: Layout.IRC });
expect(getLine(container).querySelector(".mx_EventTile_footer")).not.toBeNull();
expect(getTile(container).querySelector(":scope > .mx_EventTile_footer")).toBeNull();
});
it("renders a bubble footer for an own pinned message", () => {
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
const ownEvent = makeOwnMessage();
const { container } = getComponent({ mxEvent: ownEvent, layout: Layout.Bubble });
const footer = container.querySelector(".mx_EventTile_footer");
expect(footer).not.toBeNull();
expect(footer).toHaveTextContent("Pinned message");
});
it("renders relation groups and deduplicates reactions from the same sender", () => {
const bobReaction1 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
const bobReaction2 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
const getRelationsForEvent = jest
.fn()
.mockReturnValue(makeRelations(new Map([["👍", [bobReaction1, bobReaction2]]])));
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
canReact: true,
});
const reactionButton = screen.getByRole("button", { name: /@bob:example\.org reacted with 👍/ });
expect(reactionButton).toHaveTextContent("👍1");
});
it("detects the current user's reaction when rendering relation groups", () => {
const ownReaction = makeReactionEvent(room.roomId, mxEvent.getId()!, client.getSafeUserId(), "👍");
const getRelationsForEvent = jest.fn().mockReturnValue(
makeRelations(new Map([["👍", [ownReaction]]]), {
[client.getSafeUserId()]: [ownReaction],
}),
);
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
canReact: true,
canSelfRedact: false,
});
expect(screen.getByRole("button", { name: /reacted with 👍/ })).toHaveAttribute("aria-disabled", "true");
});
});
describe("action bar", () => {
@@ -907,6 +1067,20 @@ describe("EventTile", () => {
});
});
it("renders a notice when the event has no renderer", () => {
const unsupportedEvent = mkEvent({
event: true,
type: "org.example.unsupported",
room: room.roomId,
user: "@alice:example.org",
content: {},
});
getComponent({ mxEvent: unsupportedEvent });
expect(screen.getByText("This event could not be displayed")).toBeInTheDocument();
});
it("updates msgtype-derived tile classes when an edit changes msgtype to m.emote", async () => {
const { container } = getComponent();
expect(container.querySelector(".mx_EventTile_emote")).toBeNull();
@@ -1251,6 +1425,57 @@ describe("EventTile", () => {
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted");
});
it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])(
"does not show the unencrypted warning for %s events in encrypted rooms",
(status) => {
const event = makeOwnMessage();
event.setStatus(status);
const { container } = getComponent(
{ mxEvent: event, eventSendStatus: status },
TimelineRenderingType.Room,
{
isRoomEncrypted: true,
},
);
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
},
);
it("does not show the unencrypted warning for state events in encrypted rooms", () => {
const stateEvent = mkEvent({
event: true,
type: EventType.RoomTopic,
room: room.roomId,
user: "@alice:example.org",
skey: "",
content: { topic: "Topic" },
});
const { container } = getComponent({ mxEvent: stateEvent }, TimelineRenderingType.Room, {
isRoomEncrypted: true,
});
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
});
it("does not show the unencrypted warning for redacted events in encrypted rooms", () => {
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
const { container } = getComponent({}, TimelineRenderingType.Room, {
isRoomEncrypted: true,
});
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
});
it("does not show the unencrypted warning for local-room events in encrypted rooms", () => {
const localEvent = makeTimestampedMessage({ room: "local+room" });
const { container } = getComponent({ mxEvent: localEvent }, TimelineRenderingType.Room, {
isRoomEncrypted: true,
});
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
});
});
describe("event highlighting", () => {