* Define the shared EventTile shell contract * Create a functional shared shell skeleton * Implement the thread rendering branch * Implement notification and threads-list branches * Implement file and default timeline branches * Add shared stories and complete shell tests * Adding state flags, stories and styling * Correct DOM order for IRC branch * Refine EventTileView Storybook shell and fixtures * Fix the room story for modern layout * Fix Bubble layout * Fix IRC layout * Fix ThreadsList and Thread * Fix File and Notification * Cleanup of stories and styling * Fix lint errors * Added baseline screenshots * Rename to avoid knip errors * Avoid the knip error * Fix test fail on click * Use separate div wrappers for slots instead of relying on cloning components. * Renamed names for slot classes * Changed to conditional state classes * Change layout and shape variants to classes * Clean up and reorder for better readability * Fix Sonar issues * Fix Sonar issues
521 lines
22 KiB
TypeScript
521 lines
22 KiB
TypeScript
/*
|
|
* 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, { createRef } from "react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { fireEvent, render } from "@test-utils";
|
|
import { EventTileView, type EventTileViewClassNames, type EventTileViewProps } from "./index";
|
|
import styles from "./EventTileView.module.css";
|
|
|
|
const renderState: EventTileViewProps["root"] = {
|
|
id: "event-line-1",
|
|
ariaLive: "off",
|
|
scrollToken: "event-1",
|
|
permalink: "https://example.org/event-1",
|
|
eventId: "$event-1",
|
|
layout: "group",
|
|
shape: "Room",
|
|
state: {
|
|
isOwnEvent: true,
|
|
hasReply: true,
|
|
},
|
|
};
|
|
|
|
function createProps(overrides: Partial<EventTileViewProps> = {}): EventTileViewProps {
|
|
return {
|
|
root: renderState,
|
|
classNames: {
|
|
root: "custom-root",
|
|
line: "custom-line",
|
|
slotContextMenu: "custom-context-menu",
|
|
},
|
|
slots: {
|
|
body: <span data-testid="body">Body</span>,
|
|
contextMenu: <span data-testid="context-menu">Context menu</span>,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Styling hooks consumed by the web application's EventTile PCSS.
|
|
*
|
|
* Keep these names in this test until the application styling is migrated to
|
|
* shared-component selectors. The shared shell must preserve them alongside
|
|
* its own module classes.
|
|
*/
|
|
const applicationStylingClasses = {
|
|
root: "mx_EventTile",
|
|
line: "mx_EventTile_line",
|
|
details: "mx_EventTile_details",
|
|
slotAvatar: "mx_EventTile_avatar",
|
|
senderDetails: "mx_EventTile_senderDetails",
|
|
senderDetailsLink: "mx_EventTile_senderDetailsLink",
|
|
slotBody: "mx_EventTile_body",
|
|
slotNotificationRoomLabel: "mx_EventTile_truncated",
|
|
slotNotificationBadge: "mx_NotificationBadge",
|
|
slotSender: "mx_DisambiguatedProfile",
|
|
slotTimestamp: "mx_MessageTimestamp",
|
|
slotPadlock: "mx_EventTile_e2eIcon",
|
|
slotReplyChain: "mx_EventTile_reply",
|
|
slotActionBar: "mx_MessageActionBar",
|
|
slotFooter: "mx_EventTile_footer",
|
|
slotThreadInfo: "mx_ThreadSummary",
|
|
slotReceipt: "mx_ReadReceiptGroup_container",
|
|
} satisfies Partial<EventTileViewClassNames>;
|
|
|
|
const applicationSlotClassNames: Record<string, keyof typeof applicationStylingClasses> = {
|
|
avatar: "slotAvatar",
|
|
sender: "slotSender",
|
|
body: "slotBody",
|
|
timestamp: "slotTimestamp",
|
|
padlock: "slotPadlock",
|
|
replyChain: "slotReplyChain",
|
|
actionBar: "slotActionBar",
|
|
footer: "slotFooter",
|
|
threadInfo: "slotThreadInfo",
|
|
receipt: "slotReceipt",
|
|
};
|
|
|
|
const slotClasses: Record<string, string> = {
|
|
avatar: styles.slotAvatar,
|
|
sender: styles.slotSender,
|
|
body: styles.slotBody,
|
|
contextMenu: styles.slotContextMenu,
|
|
replyChain: styles.slotReplyChain,
|
|
actionBar: styles.slotActionBar,
|
|
timestamp: styles.slotTimestamp,
|
|
padlock: styles.slotPadlock,
|
|
footer: styles.slotFooter,
|
|
threadInfo: styles.slotThreadInfo,
|
|
receipt: styles.slotReceipt,
|
|
roomAvatar: styles.slotAvatar,
|
|
notificationRoomLabel: styles.slotNotificationRoomLabel,
|
|
notificationBadge: styles.slotNotificationBadge,
|
|
};
|
|
|
|
function createStylingContractSlots(): EventTileViewProps["slots"] {
|
|
const slot = (name: string): React.ReactElement => <span data-testid={`styling-contract-${name}`}>{name}</span>;
|
|
|
|
return {
|
|
avatar: slot("avatar"),
|
|
sender: slot("sender"),
|
|
body: slot("body"),
|
|
timestamp: slot("timestamp"),
|
|
padlock: slot("padlock"),
|
|
replyChain: slot("replyChain"),
|
|
actionBar: slot("actionBar"),
|
|
footer: slot("footer"),
|
|
threadInfo: slot("threadInfo"),
|
|
receipt: slot("receipt"),
|
|
roomAvatar: slot("room-avatar"),
|
|
notificationRoomLabel: slot("notificationRoomLabel"),
|
|
notificationBadge: slot("notificationBadge"),
|
|
contextMenu: slot("contextMenu"),
|
|
};
|
|
}
|
|
|
|
describe("EventTileView", () => {
|
|
it("renders the common root and line structure", () => {
|
|
const { container, getByTestId } = render(<EventTileView {...createProps()} />);
|
|
const root = container.firstElementChild;
|
|
const line = getByTestId("body").closest(".custom-line");
|
|
const contextMenu = getByTestId("context-menu").parentElement;
|
|
|
|
expect(root).toHaveClass("custom-root");
|
|
expect(root).toHaveAttribute("aria-live", "off");
|
|
expect(root).toHaveAttribute("aria-atomic", "true");
|
|
expect(root).toHaveAttribute("data-scroll-tokens", "event-1");
|
|
expect(root).toHaveAttribute("data-event-id", "$event-1");
|
|
expect(root).toHaveClass(styles.layoutGroup);
|
|
expect(root).toHaveClass(styles.stateOwnEvent);
|
|
expect(line).toHaveClass("custom-line");
|
|
expect(line).toHaveAttribute("id", "event-line-1");
|
|
expect(getByTestId("context-menu")).toBeInTheDocument();
|
|
expect(contextMenu).toHaveClass("custom-context-menu");
|
|
expect(contextMenu).toHaveAttribute("data-testid", "event-tile-slot-contextMenu");
|
|
});
|
|
|
|
it("exposes shell state through application-neutral state classes", () => {
|
|
const { container } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: {
|
|
...renderState,
|
|
state: {
|
|
...renderState.state,
|
|
highlighted: true,
|
|
selected: true,
|
|
editing: true,
|
|
continuation: true,
|
|
lastInSection: true,
|
|
},
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
const root = container.firstElementChild!;
|
|
|
|
expect(root).toHaveClass(styles.stateHighlighted);
|
|
expect(root).toHaveClass(styles.stateSelected);
|
|
expect(root).toHaveClass(styles.stateEditing);
|
|
expect(root).toHaveClass(styles.stateContinuation);
|
|
expect(root).toHaveClass(styles.stateLastInSection);
|
|
});
|
|
|
|
it("preserves the application styling contract across rendering modes", () => {
|
|
const group = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: applicationStylingClasses,
|
|
slots: createStylingContractSlots(),
|
|
})}
|
|
/>,
|
|
);
|
|
const groupRoot = group.container.firstElementChild!;
|
|
|
|
expect(groupRoot).toHaveClass(applicationStylingClasses.root);
|
|
expect(
|
|
group.getByTestId("styling-contract-body").closest(`.${applicationStylingClasses.line}`),
|
|
).toBeInTheDocument();
|
|
|
|
for (const slot of [
|
|
"sender",
|
|
"avatar",
|
|
"body",
|
|
"timestamp",
|
|
"padlock",
|
|
"replyChain",
|
|
"actionBar",
|
|
"footer",
|
|
"threadInfo",
|
|
"receipt",
|
|
]) {
|
|
expect(
|
|
group.getByTestId(`styling-contract-${slot}`).closest('[data-testid^="event-tile-slot-"]'),
|
|
).toHaveClass(applicationStylingClasses[applicationSlotClassNames[slot]]);
|
|
expect(
|
|
group.getByTestId(`styling-contract-${slot}`).closest('[data-testid^="event-tile-slot-"]'),
|
|
).toHaveClass(slotClasses[slot]);
|
|
expect(
|
|
group.getByTestId(`styling-contract-${slot}`).closest('[data-testid^="event-tile-slot-"]'),
|
|
).toHaveAttribute("data-testid", `event-tile-slot-${slot}`);
|
|
}
|
|
|
|
const thread = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: applicationStylingClasses,
|
|
root: { ...renderState, shape: "Thread" },
|
|
slots: createStylingContractSlots(),
|
|
})}
|
|
/>,
|
|
);
|
|
expect(thread.container.querySelector(".mx_EventTile_senderDetails")).toHaveClass(
|
|
applicationStylingClasses.senderDetails,
|
|
);
|
|
|
|
const preview = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: applicationStylingClasses,
|
|
root: { ...renderState, shape: "Notification" },
|
|
slots: createStylingContractSlots(),
|
|
})}
|
|
/>,
|
|
);
|
|
expect(preview.container.querySelector(".mx_EventTile_details")).toHaveClass(applicationStylingClasses.details);
|
|
expect(preview.getByTestId("styling-contract-notificationRoomLabel").parentElement).toHaveClass(
|
|
applicationStylingClasses.slotNotificationRoomLabel,
|
|
);
|
|
expect(preview.getByTestId("styling-contract-notificationBadge").parentElement).toHaveClass(
|
|
applicationStylingClasses.slotNotificationBadge,
|
|
);
|
|
expect(preview.getByTestId("styling-contract-room-avatar").parentElement).toHaveClass(
|
|
applicationStylingClasses.slotAvatar,
|
|
);
|
|
|
|
const file = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: applicationStylingClasses,
|
|
root: { ...renderState, shape: "File" },
|
|
slots: createStylingContractSlots(),
|
|
})}
|
|
/>,
|
|
);
|
|
expect(file.container.querySelector(".mx_EventTile_senderDetailsLink")).toHaveClass(
|
|
applicationStylingClasses.senderDetailsLink,
|
|
);
|
|
});
|
|
|
|
it("renders the thread layout in the original slot order", () => {
|
|
const { container, getByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: {
|
|
senderDetails: "legacy-sender-details",
|
|
},
|
|
root: {
|
|
...renderState,
|
|
shape: "Thread",
|
|
},
|
|
slots: {
|
|
avatar: <span data-testid="avatar">Avatar</span>,
|
|
sender: <span data-testid="sender">Sender</span>,
|
|
replyChain: <span data-testid="reply-chain">Reply chain</span>,
|
|
body: <span data-testid="body">Body</span>,
|
|
actionBar: <span data-testid="action-bar">Action bar</span>,
|
|
timestamp: <span data-testid="timestamp">Timestamp</span>,
|
|
receipt: <span data-testid="receipt">Receipt</span>,
|
|
footer: <span data-testid="footer">Footer</span>,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
const root = container.firstElementChild!;
|
|
const senderDetails = getByTestId("avatar").parentElement?.parentElement;
|
|
const line = getByTestId("body").parentElement?.parentElement;
|
|
|
|
if (!senderDetails || !line) {
|
|
throw new Error("Expected EventTile thread layout elements to be present");
|
|
}
|
|
|
|
expect(senderDetails).toContainElement(getByTestId("sender"));
|
|
expect(senderDetails).toHaveClass("legacy-sender-details");
|
|
expect(senderDetails).toBe(root.firstElementChild);
|
|
expect(line).toContainElement(getByTestId("reply-chain"));
|
|
expect(line).toContainElement(getByTestId("action-bar"));
|
|
expect(line).toContainElement(getByTestId("timestamp"));
|
|
expect(line).toContainElement(getByTestId("receipt"));
|
|
expect(getByTestId("footer").parentElement?.parentElement).toBe(root);
|
|
});
|
|
|
|
it.each(["Notification", "ThreadsList"] as const)("renders the %s preview layout", (shape) => {
|
|
const { container, getByTestId, queryByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
classNames: {
|
|
details: "legacy-details",
|
|
slotAvatar: "legacy-avatar",
|
|
slotActionBar: "legacy-thread-action-bar",
|
|
},
|
|
root: {
|
|
...renderState,
|
|
shape,
|
|
},
|
|
slots: {
|
|
sender: <span data-testid="sender">Sender</span>,
|
|
notificationRoomLabel:
|
|
shape === "Notification" ? <span data-testid="room-label">Room</span> : undefined,
|
|
timestamp: <span data-testid="timestamp">Timestamp</span>,
|
|
notificationBadge: <span data-testid="badge">Badge</span>,
|
|
roomAvatar: <span data-testid="room-avatar">Room avatar</span>,
|
|
avatar: <span data-testid="avatar">Avatar</span>,
|
|
body: <span data-testid="body">Body</span>,
|
|
threadInfo: <span data-testid="thread-info">Thread info</span>,
|
|
actionBar: <span data-testid="action-bar">Action bar</span>,
|
|
receipt: <span data-testid="receipt">Receipt</span>,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
const root = container.firstElementChild!;
|
|
const details = getByTestId("sender").parentElement?.parentElement;
|
|
const line = getByTestId("body").parentElement?.parentElement;
|
|
|
|
if (!details || !line) {
|
|
throw new Error(`Expected EventTile ${shape} layout elements to be present`);
|
|
}
|
|
|
|
expect(root).toHaveAttribute("tabindex", "-1");
|
|
expect(root).toHaveClass(shape === "Notification" ? styles.shapeNotification : styles.shapeThreadsList);
|
|
expect(details).toContainElement(getByTestId("timestamp"));
|
|
expect(details).toHaveClass("legacy-details");
|
|
expect(details).toContainElement(getByTestId("badge"));
|
|
expect(line).toContainElement(getByTestId("thread-info"));
|
|
expect(getByTestId("receipt").parentElement?.parentElement).toBe(root);
|
|
|
|
if (shape === "Notification") {
|
|
const avatar = getByTestId("room-avatar").parentElement!;
|
|
expect(avatar.parentElement).toBe(root);
|
|
expect(avatar).toHaveClass("legacy-avatar");
|
|
expect(queryByTestId("action-bar")).not.toBeInTheDocument();
|
|
} else {
|
|
const avatar = getByTestId("avatar").parentElement!;
|
|
const actionBar = getByTestId("action-bar").parentElement!;
|
|
expect(avatar.parentElement).toBe(root);
|
|
expect(actionBar.parentElement).toBe(root);
|
|
expect(actionBar).toHaveClass("legacy-thread-action-bar");
|
|
expect(queryByTestId("room-avatar")).not.toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it("renders the file layout with permalink interactions", () => {
|
|
const onPermalinkClick = vi.fn((event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
event.preventDefault();
|
|
});
|
|
const onPermalinkContextMenu = vi.fn();
|
|
const { container, getByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: { ...renderState, shape: "File" },
|
|
classNames: {
|
|
senderDetails: "legacy-sender-details",
|
|
senderDetailsLink: "legacy-sender-details-link",
|
|
},
|
|
onPermalinkClick,
|
|
onPermalinkContextMenu,
|
|
slots: {
|
|
avatar: <span data-testid="avatar">Avatar</span>,
|
|
sender: <span data-testid="sender">Sender</span>,
|
|
timestamp: <span data-testid="timestamp">Timestamp</span>,
|
|
body: <span data-testid="body">Body</span>,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
const root = container.firstElementChild!;
|
|
const link = getByTestId("sender").closest("a");
|
|
const senderDetails = getByTestId("sender").closest(".legacy-sender-details");
|
|
|
|
expect(root).toHaveClass(styles.shapeFile);
|
|
|
|
if (!link) {
|
|
throw new Error("Expected sender details link");
|
|
}
|
|
if (!senderDetails) {
|
|
throw new Error("Expected sender details container");
|
|
}
|
|
|
|
expect(link).toHaveAttribute("href", renderState.permalink);
|
|
expect(link).toHaveClass("legacy-sender-details-link");
|
|
expect(senderDetails).toHaveClass("legacy-sender-details");
|
|
expect(senderDetails).toContainElement(getByTestId("timestamp"));
|
|
expect(getByTestId("body").closest("#event-line-1")).toBe(root.lastElementChild);
|
|
|
|
fireEvent.click(link);
|
|
fireEvent.contextMenu(senderDetails);
|
|
expect(onPermalinkClick).toHaveBeenCalledOnce();
|
|
expect(onPermalinkContextMenu).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("falls back to a safe file permalink", () => {
|
|
const { getByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: { ...renderState, permalink: undefined, shape: "File" },
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(getByTestId("body").closest("#event-line-1")?.previousElementSibling).toHaveAttribute("href", "#");
|
|
});
|
|
|
|
it.each(["group", "irc"] as const)("renders the %s timeline layout", (layout) => {
|
|
const { container, getByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: { ...renderState, layout },
|
|
slots: {
|
|
sender: <span data-testid="sender">Sender</span>,
|
|
avatar: <span data-testid="avatar">Avatar</span>,
|
|
timestamp: <span data-testid="timestamp">Timestamp</span>,
|
|
padlock: <span data-testid="padlock">Padlock</span>,
|
|
replyChain: <span data-testid="reply-chain">Reply chain</span>,
|
|
body: <span data-testid="body">Body</span>,
|
|
actionBar: <span data-testid="action-bar">Action bar</span>,
|
|
footer: <span data-testid="footer">Footer</span>,
|
|
threadInfo: <span data-testid="thread-info">Thread info</span>,
|
|
receipt: <span data-testid="receipt">Receipt</span>,
|
|
contextMenu: <span data-testid="context-menu">Context menu</span>,
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
const root = container.firstElementChild!;
|
|
const line = getByTestId("body").parentElement?.parentElement;
|
|
|
|
if (!line) {
|
|
throw new Error(`Expected EventTile ${layout} layout line to be present`);
|
|
}
|
|
|
|
expect(root).toHaveAttribute("tabindex", "-1");
|
|
expect(root).toHaveClass(layout === "irc" ? styles.layoutIrc : styles.layoutGroup);
|
|
expect(line).toContainElement(getByTestId("reply-chain"));
|
|
expect(line).toContainElement(getByTestId("action-bar"));
|
|
|
|
if (layout === "irc") {
|
|
expect(root.children[0]).toContainElement(getByTestId("padlock"));
|
|
expect(root.children[1]).toContainElement(getByTestId("timestamp"));
|
|
expect(root.children[2]).toContainElement(getByTestId("avatar"));
|
|
expect(root.children[3]).toContainElement(getByTestId("sender"));
|
|
expect(root.children[4]).toBe(line);
|
|
expect(root.children[5]).toContainElement(getByTestId("receipt"));
|
|
expect(getByTestId("padlock").parentElement?.parentElement).toBe(root);
|
|
expect(getByTestId("footer").parentElement?.parentElement).toBe(line);
|
|
expect(getByTestId("thread-info").parentElement?.parentElement).toBe(line);
|
|
} else {
|
|
expect(getByTestId("timestamp").parentElement?.parentElement).toBe(line);
|
|
expect(getByTestId("padlock").parentElement?.parentElement).toBe(line);
|
|
expect(getByTestId("footer").parentElement?.parentElement).toBe(root);
|
|
expect(getByTestId("thread-info").parentElement?.parentElement).toBe(root);
|
|
}
|
|
|
|
expect(getByTestId("receipt").parentElement?.parentElement).toBe(root);
|
|
});
|
|
|
|
it("forwards root and line interactions", () => {
|
|
const onClick = vi.fn();
|
|
const onContextMenu = vi.fn();
|
|
const { container, getByTestId } = render(<EventTileView {...createProps({ onClick, onContextMenu })} />);
|
|
|
|
fireEvent.click(container.firstElementChild!);
|
|
fireEvent.contextMenu(getByTestId("body").parentElement!);
|
|
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
expect(onContextMenu).toHaveBeenCalledOnce();
|
|
|
|
const { container: previewContainer } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: { ...renderState, shape: "Notification" },
|
|
onClick,
|
|
})}
|
|
/>,
|
|
);
|
|
fireEvent.click(previewContainer.firstElementChild!);
|
|
expect(onClick).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not attach a context menu handler to preview lines", () => {
|
|
const onContextMenu = vi.fn();
|
|
const { getByTestId } = render(
|
|
<EventTileView
|
|
{...createProps({
|
|
root: { ...renderState, shape: "Notification" },
|
|
onContextMenu,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.contextMenu(getByTestId("body").parentElement!);
|
|
expect(onContextMenu).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards the root ref and supports a custom root element", () => {
|
|
const rootRef = createRef<HTMLElement>();
|
|
const { container } = render(
|
|
<EventTileView {...createProps({ root: { ...renderState, as: "article" }, refs: { root: rootRef } })} />,
|
|
);
|
|
|
|
expect(container.firstElementChild?.tagName).toBe("ARTICLE");
|
|
expect(rootRef.current).toBe(container.firstElementChild);
|
|
});
|
|
});
|