/* * 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 { return { root: renderState, classNames: { root: "custom-root", line: "custom-line", slotContextMenu: "custom-context-menu", }, slots: { body: Body, contextMenu: Context menu, }, ...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; const applicationSlotClassNames: Record = { avatar: "slotAvatar", sender: "slotSender", body: "slotBody", timestamp: "slotTimestamp", padlock: "slotPadlock", replyChain: "slotReplyChain", actionBar: "slotActionBar", footer: "slotFooter", threadInfo: "slotThreadInfo", receipt: "slotReceipt", }; const slotClasses: Record = { 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 => {name}; 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(); 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( , ); 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( , ); 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( , ); expect(thread.container.querySelector(".mx_EventTile_senderDetails")).toHaveClass( applicationStylingClasses.senderDetails, ); const preview = render( , ); 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( , ); expect(file.container.querySelector(".mx_EventTile_senderDetailsLink")).toHaveClass( applicationStylingClasses.senderDetailsLink, ); }); it("renders the thread layout in the original slot order", () => { const { container, getByTestId } = render( Avatar, sender: Sender, replyChain: Reply chain, body: Body, actionBar: Action bar, timestamp: Timestamp, receipt: Receipt, footer: Footer, }, })} />, ); 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( Sender, notificationRoomLabel: shape === "Notification" ? Room : undefined, timestamp: Timestamp, notificationBadge: Badge, roomAvatar: Room avatar, avatar: Avatar, body: Body, threadInfo: Thread info, actionBar: Action bar, receipt: Receipt, }, })} />, ); 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) => { event.preventDefault(); }); const onPermalinkContextMenu = vi.fn(); const { container, getByTestId } = render( Avatar, sender: Sender, timestamp: Timestamp, body: Body, }, })} />, ); 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( , ); 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( Sender, avatar: Avatar, timestamp: Timestamp, padlock: Padlock, replyChain: Reply chain, body: Body, actionBar: Action bar, footer: Footer, threadInfo: Thread info, receipt: Receipt, contextMenu: Context menu, }, })} />, ); 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(); fireEvent.click(container.firstElementChild!); fireEvent.contextMenu(getByTestId("body").parentElement!); expect(onClick).not.toHaveBeenCalled(); expect(onContextMenu).toHaveBeenCalledOnce(); const { container: previewContainer } = render( , ); 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( , ); fireEvent.contextMenu(getByTestId("body").parentElement!); expect(onContextMenu).not.toHaveBeenCalled(); }); it("forwards the root ref and supports a custom root element", () => { const rootRef = createRef(); const { container } = render( , ); expect(container.firstElementChild?.tagName).toBe("ARTICLE"); expect(rootRef.current).toBe(container.firstElementChild); }); });