Files
ThreadNet-Web/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.test.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

521 lines
22 KiB
TypeScript
Raw Normal View History

/*
* 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);
});
});