Move EventTile to shared components - #4b (#34565)

* 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
This commit is contained in:
rbondesson
2026-08-12 06:53:40 +00:00
committed by GitHub
parent 7711207393
commit 72d8d9f60f
19 changed files with 2633 additions and 9 deletions
@@ -268,9 +268,9 @@ export interface EventTileRootData {
layout?: Layout;
/** The timeline rendering mode exposed through `data-shape`. */
shape: TimelineRenderingType;
/** Whether the event belongs to the current user, exposed through `data-self`. */
/** Whether the event belongs to the current user, exposed through `stateOwnEvent`. */
isOwnEvent: boolean;
/** Whether EventTile renders a reply chain, exposed through `data-has-reply`. */
/** Whether EventTile renders a reply chain, exposed through `stateHasReply`. */
hasReply: boolean;
}
+1
View File
@@ -50,6 +50,7 @@ export * from "./room/timeline/event-tile/EventTileView/E2eMessageSharedIcon";
export * from "./room/timeline/event-tile/EventTileView/E2ePadlock";
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
export * from "./room/timeline/event-tile/EventTileView/EventPreviewView";
export * from "./room/timeline/event-tile/EventTileView";
export * from "./room/timeline/event-tile/call";
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView";
@@ -0,0 +1,12 @@
/*
* 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.
*/
/** Event tile layout selected by the host surface. */
export type EventLayout = "group" | "bubble" | "irc";
/** Density variant applied within an event layout. */
export type EventDensity = "default" | "compact";
@@ -7,11 +7,7 @@
import { createContext, useContext } from "react";
/** Event tile layout selected by the host surface. */
export type EventLayout = "group" | "bubble" | "irc";
/** Density variant applied within an event layout. */
export type EventDensity = "default" | "compact";
import type { EventDensity, EventLayout } from "./EventPresentation.types";
/** Presentation settings that shared event/timeline components can adapt to. */
export interface EventPresentation {
@@ -9,7 +9,6 @@ export {
DEFAULT_EVENT_PRESENTATION,
EventPresentationProvider,
useEventPresentation,
type EventDensity,
type EventLayout,
type EventPresentation,
} from "./EventPresentationContext";
export type { EventDensity, EventLayout } from "./EventPresentation.types";
@@ -0,0 +1,783 @@
/*
* 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.
*/
/* Shared root and slot-boundary baseline. */
.root {
/* Group geometry. */
--event-tile-group-gutter: 64px;
--event-tile-group-line-spacing-block-start: 1px;
--event-tile-group-line-spacing-block-end: 3px;
--event-tile-group-line-height: 22px;
/* IRC geometry. */
--event-tile-irc-line-height: 18px;
--event-tile-irc-name-width: 80px;
--event-tile-irc-icon-width: 14px;
--event-tile-irc-timestamp-width: 48px;
--event-tile-irc-right-padding: 5px;
/* Bubble geometry. */
--event-tile-bubble-corner-radius: 12px;
--event-tile-bubble-gap: 5px;
--event-tile-bubble-gutter: 11px;
--event-tile-bubble-other-background: var(--cpd-color-gray-300, #e9edf2);
--event-tile-bubble-self-background: var(--cpd-color-green-300, #d8f5ed);
/* Shared preview, action-bar, bubble, and interaction values. */
--event-tile-preview-offset: 48px;
--event-tile-action-bar-size: 28px;
--event-tile-action-bar-margin: 3px;
--event-tile-action-bar-offset: 8px;
--event-tile-state-hover-background: var(--cpd-color-bg-subtle-secondary, #e9edf2);
--event-tile-state-highlight-background: var(--cpd-color-yellow-200, #fff8e3);
--event-tile-state-selected-background: var(--cpd-color-bg-subtle-secondary, #e9edf2);
--event-tile-state-marker-width: 4px;
--event-tile-state-marker-color: var(--cpd-color-bg-action-primary-rest, #0dbd8b);
position: relative;
flex-shrink: 0;
max-width: 100%;
clear: both;
padding-top: 18px;
&.stateContinuation {
padding-top: 0;
}
}
.line {
position: relative;
min-width: 0;
}
/* Slot wrappers are the public styling boundary. The supplied content may be
a component, fragment, or multiple elements and does not need to forward
layout props. */
.slotActionBar,
.slotAvatar,
.slotBody,
.slotContextMenu,
.slotFooter,
.slotNotificationBadge,
.slotNotificationRoomLabel,
.slotPadlock,
.slotReceipt,
.slotReplyChain,
.slotSender,
.slotThreadInfo,
.slotTimestamp {
min-width: 0;
}
.slotContextMenu {
z-index: 1;
}
/* Shared structural containers. */
.senderDetails {
display: flex;
align-items: flex-start;
}
.details {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--cpd-space-2x, 8px);
}
/* The shell owns the action-bar boundary. The action-bar contents own their controls.
Presence of the slot is the render/show signal supplied by the host. */
.slotActionBar {
position: absolute;
z-index: 10;
inset-block-start: calc(
-1 *
(var(--event-tile-action-bar-size) + 2 *
(var(--event-tile-action-bar-margin) + var(--cpd-border-width-1, 1px)))
);
inset-inline-end: var(--event-tile-action-bar-offset);
user-select: none;
}
/* Group layout. */
.root.layoutGroup {
&:hover .line,
&:focus-within .line {
background-color: var(--event-tile-state-hover-background);
}
&.stateHighlighted .line {
background-color: var(--event-tile-state-highlight-background);
}
&.stateSelected .line,
&.stateEditing .line {
background-color: var(--event-tile-state-selected-background);
}
&.stateSelected .line::before,
&.stateEditing .line::before {
content: "";
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
width: var(--event-tile-state-marker-width);
border-start-start-radius: inherit;
border-end-start-radius: inherit;
background: var(--event-tile-state-marker-color);
pointer-events: none;
}
.line {
padding-block: var(--event-tile-group-line-spacing-block-start) var(--event-tile-group-line-spacing-block-end);
padding-inline-start: var(--event-tile-group-gutter);
margin-inline-end: 80px;
min-height: 14px;
border-radius: 8px;
line-height: var(--event-tile-group-line-height);
}
.slotSender {
display: flex;
align-items: center;
max-width: calc(100% - var(--event-tile-group-gutter));
margin-inline-start: var(--event-tile-group-gutter);
min-height: 26px;
line-height: 20px;
}
.slotAvatar {
position: absolute;
z-index: 9;
inset-block-start: 14px;
inset-inline-start: var(--cpd-space-2x, 8px);
}
.slotTimestamp {
position: absolute;
inset-inline-start: 0;
width: var(--MessageTimestamp-width, 46px);
text-align: center;
}
.slotPadlock {
position: absolute;
inset-block-start: 0;
inset-inline-start: 44px;
height: var(--event-tile-group-line-height);
margin: 1px;
}
.slotThreadInfo {
margin-inline-start: var(--event-tile-group-gutter);
}
.slotReplyChain {
margin-inline-start: 0;
margin-bottom: var(--cpd-space-2x, 8px);
}
.slotFooter {
margin: var(--cpd-space-1x, 4px) 0 var(--cpd-space-1x, 4px) var(--event-tile-group-gutter);
}
.slotReceipt {
margin-inline-end: 10px;
float: inline-end;
position: relative;
inset-block-start: calc(-1 * (var(--event-tile-group-line-height) + 3px));
width: 90px;
height: 1px;
text-align: end;
overflow: visible;
}
&.stateContinuation .line {
clear: both;
}
}
/* IRC layout. */
.root.layoutIrc {
display: flex;
align-items: flex-start;
padding-top: 0;
line-height: var(--event-tile-irc-line-height);
&:hover .line,
&:focus-within .line {
background-color: var(--event-tile-state-hover-background);
}
&.stateHighlighted .line {
background-color: var(--event-tile-state-highlight-background);
}
&.stateSelected .line,
&.stateEditing .line {
background-color: var(--event-tile-state-selected-background);
}
&.stateSelected .line::before,
&.stateEditing .line::before {
content: "";
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
width: var(--event-tile-state-marker-width);
background: var(--event-tile-state-marker-color);
pointer-events: none;
}
> .slotActionBar,
> .slotAvatar,
> .slotBody,
> .slotContextMenu,
> .slotFooter,
> .slotNotificationBadge,
> .slotNotificationRoomLabel,
> .slotPadlock,
> .slotReceipt,
> .slotReplyChain,
> .slotSender,
> .slotThreadInfo,
> .slotTimestamp,
> .line {
margin-inline-end: var(--event-tile-irc-right-padding);
}
.slotTimestamp {
min-width: var(--event-tile-irc-timestamp-width);
flex-shrink: 0;
text-align: end;
}
.slotAvatar {
display: flex;
align-items: center;
flex: 0 0 var(--event-tile-irc-icon-width);
position: relative;
width: var(--event-tile-irc-icon-width);
height: var(--event-tile-irc-line-height);
}
.slotSender {
flex-shrink: 0;
width: var(--event-tile-irc-name-width);
margin-inline-end: 0;
}
.slotPadlock,
.slotFooter,
.slotThreadInfo,
.slotReceipt {
flex-shrink: 0;
}
.slotPadlock {
position: absolute;
}
.line {
display: flex;
flex-direction: column;
flex-grow: 1;
flex-shrink: 1;
padding-block: 1px;
}
.slotReplyChain {
margin: 0;
}
.slotFooter {
margin-block: var(--cpd-space-1-5x, 6px);
}
}
/* Bubble layout. */
.root.layoutBubble {
max-width: unset;
margin-inline: var(--EventTile_bubble-margin-inline-start, 49px) var(--EventTile_bubble-margin-inline-end, 60px);
padding-top: 0;
&::before {
content: "";
position: absolute;
z-index: -1;
inset: -1px calc(-1 * var(--EventTile_bubble-margin-inline-end, 60px)) -1px
calc(-1 * var(--EventTile_bubble-margin-inline-start, 49px));
border-radius: 4px;
pointer-events: none;
}
/* Bubble geometry and slot placement. */
.line {
display: flex;
flex-direction: column;
gap: var(--event-tile-bubble-gap);
width: fit-content;
max-width: 70%;
margin-block: 0;
margin-inline: -9px -12px;
border-radius: var(--event-tile-bubble-corner-radius) var(--event-tile-bubble-corner-radius) 0 0;
line-height: 18px;
padding: calc(var(--event-tile-bubble-gutter) - 1px);
padding-inline-end: 60px;
background: var(--event-tile-bubble-other-background);
border-end-end-radius: var(--event-tile-bubble-corner-radius);
}
.slotAvatar {
position: absolute;
z-index: 9;
inset-block-start: 6px;
inset-inline-start: -36px;
line-height: 0;
border: 4px solid var(--cpd-color-bg-canvas-default, #fff);
border-radius: 50%;
}
.slotAvatar > * {
border-radius: 50%;
box-shadow: 0 0 0 3px var(--cpd-color-bg-canvas-default, #fff);
}
.slotSender,
.line {
width: fit-content;
max-width: 70%;
line-height: 18px;
}
.slotSender {
position: relative;
inset-block-start: -2px;
inset-inline-start: 2px;
font-size: 15px;
white-space: normal;
}
.slotTimestamp {
position: absolute;
z-index: 3;
inset-block-end: 0;
inset-inline-end: 0;
padding: 4px 8px;
}
> .slotReceipt {
position: absolute;
z-index: 1;
inset-block-end: -3px;
inset-inline-end: -78px;
}
.slotPadlock {
flex-shrink: 0;
height: calc(18px + 2px);
}
.slotActionBar {
inset-block-start: -28px;
inset-inline-start: calc(
100% - var(--event-tile-action-bar-size) - 2 * (var(--event-tile-action-bar-margin) + 1px)
);
inset-inline-end: auto;
}
.slotFooter {
display: flex;
align-items: center;
margin-block: var(--cpd-space-1-5x, 6px);
margin-inline: -9px -12px;
}
.slotThreadInfo {
margin-inline-start: -11px;
margin-inline-end: auto;
}
.slotReplyChain,
.slotThreadInfo {
clear: both;
}
/* Own-event modifiers. */
&.stateOwnEvent .slotSender {
display: none;
}
&.stateOwnEvent .line {
border-end-start-radius: var(--event-tile-bubble-corner-radius);
border-end-end-radius: 0;
background: var(--event-tile-bubble-self-background);
margin-inline-start: auto;
}
&.stateOwnEvent .slotAvatar {
inset-block-start: -19px;
inset-inline-start: auto;
inset-inline-end: -38px;
}
&.stateOwnEvent .slotActionBar {
inset-block-start: -28px;
inset-inline-start: auto;
inset-inline-end: 0;
}
&.stateOwnEvent .slotFooter {
justify-content: flex-end;
}
&.stateOwnEvent .slotThreadInfo {
margin-inline-start: auto;
margin-inline-end: -11px;
}
/* Interaction state. */
&.stateSelected .line::before {
content: "";
position: absolute;
z-index: 0;
inset-block: 0;
inset-inline-start: 0;
width: var(--event-tile-state-marker-width);
border-start-start-radius: inherit;
border-end-start-radius: inherit;
background: var(--event-tile-state-marker-color);
pointer-events: none;
}
&:hover::before,
&.stateSelected::before {
background: var(--event-tile-state-hover-background);
}
&.stateHighlighted::before {
background: var(--event-tile-state-highlight-background);
}
&:hover .slotAvatar {
border-color: var(--event-tile-state-hover-background);
}
&.stateHighlighted .slotAvatar {
border-color: var(--event-tile-state-highlight-background);
}
&:hover .slotAvatar > *,
&.stateSelected .slotAvatar > * {
box-shadow: 0 0 0 3px var(--event-tile-state-hover-background);
}
&.stateHighlighted .slotAvatar > * {
box-shadow: 0 0 0 3px var(--event-tile-state-highlight-background);
}
/* Section state. */
&.stateContinuation {
margin-top: 2px;
}
&.stateContinuation .line {
border-start-start-radius: 0;
}
&.stateContinuation.stateOwnEvent .line {
border-start-start-radius: var(--event-tile-bubble-corner-radius);
border-start-end-radius: 0;
}
&.stateLastInSection {
margin-bottom: 24px;
}
&.stateLastInSection .line {
border-end-start-radius: var(--event-tile-bubble-corner-radius);
}
&.stateLastInSection.stateOwnEvent .line {
border-end-start-radius: 0;
border-end-end-radius: var(--event-tile-bubble-corner-radius);
}
}
/* ThreadView adds a narrower group timeline around EventTile. */
.root.shapeThread {
display: flex;
flex-direction: column;
width: 100%;
padding-top: 0;
&.layoutGroup {
.senderDetails {
align-items: center;
gap: var(--cpd-space-4x, 16px);
padding-inline-start: var(--cpd-space-2x, 8px);
}
.senderDetails .slotAvatar {
/* ThreadView keeps the avatar in the sender-details flex row. */
position: relative;
inset: auto;
}
.senderDetails .slotSender {
margin-inline-start: 0;
max-width: 100%;
min-height: 0;
line-height: normal;
}
.line {
padding-block: 2px;
padding-inline-start: 56px;
margin-inline-end: 8px;
}
.slotTimestamp {
/* ThreadView aligns this with the event content, not the bottom of
the line's flex/content flow. */
inset-block-start: 2px;
}
.slotFooter {
margin-inline-start: 56px;
margin-inline-end: 8px;
}
}
}
/* ThreadsList layout. */
.root.shapeThreadsList {
display: flex;
flex-flow: row wrap;
align-items: center;
margin-block: 13px;
padding: 8px;
border-radius: 8px;
&:hover {
background-color: var(--cpd-color-bg-subtle-primary, #f0f1f2);
}
&::after {
content: "";
position: absolute;
inset: auto -8px -13px 56px;
height: 1px;
}
&:last-child::after {
content: unset;
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
.details {
width: calc(100% - var(--event-tile-preview-offset));
margin-inline-start: var(--event-tile-preview-offset);
overflow: hidden;
}
.slotSender {
/* In preview tiles the sender is a metadata-row profile, not the
group-layout sender above the event line. */
display: inline-flex;
align-items: center;
flex: 1;
max-width: 100%;
min-height: 0;
margin-inline-start: 0;
line-height: normal;
}
.slotAvatar {
position: absolute;
inset: 8px auto auto 8px;
}
.line {
width: 100%;
box-sizing: border-box;
padding-inline-start: var(--event-tile-preview-offset);
padding-bottom: 0;
}
.slotThreadInfo {
/* The preview is inside the padded line in this mode; it does not use
the group-layout thread-summary gutter. */
margin-inline-start: 0;
}
.slotTimestamp {
display: block;
font-size: 12px;
overflow: hidden;
margin-inline-start: auto;
align-self: flex-start;
position: initial;
inset: auto;
width: unset;
text-align: initial;
text-overflow: ellipsis;
}
&.layoutGroup:hover .line {
background-color: inherit;
box-shadow: none;
}
}
/* Notification layout. */
.root.shapeNotification {
display: flex;
flex-flow: row wrap;
align-items: center;
margin-block: 13px;
padding: 8px;
border-radius: 8px;
&:hover {
background-color: var(--cpd-color-bg-subtle-primary, #f0f1f2);
}
&::after {
content: "";
position: absolute;
inset: auto -8px -13px 56px;
height: 1px;
}
&:last-child::after {
content: unset;
}
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
.details {
width: calc(100% - var(--event-tile-preview-offset));
margin-inline-start: var(--event-tile-preview-offset);
overflow: hidden;
}
.slotSender {
/* In preview tiles the sender is a metadata-row profile, not the
group-layout sender above the event line. */
display: inline-flex;
align-items: center;
flex: 1;
max-width: 100%;
min-height: 0;
margin-inline-start: 0;
line-height: normal;
}
.slotAvatar {
position: absolute;
inset: 8px auto auto 8px;
}
.line {
width: 100%;
box-sizing: border-box;
padding-inline-start: var(--event-tile-preview-offset);
padding-bottom: 0;
}
.slotThreadInfo {
/* The preview is inside the padded line in these modes; it does not use
the group-layout thread-summary gutter. */
margin-inline-start: 0;
}
.slotTimestamp {
display: block;
font-size: 12px;
overflow: hidden;
margin-inline-start: auto;
align-self: flex-start;
position: initial;
inset: auto;
width: unset;
text-align: initial;
text-overflow: ellipsis;
}
&.layoutGroup:hover .line {
background-color: inherit;
box-shadow: none;
}
}
/* File panel layout. The application File branch does not apply a timeline
layout class, so explicitly reset the group-layout defaults owned by this
shared shell. */
.root.shapeFile {
padding-top: 0;
overflow-wrap: anywhere;
.senderDetailsLink {
display: block;
margin-bottom: var(--cpd-space-1x, 4px);
text-decoration: none;
}
.senderDetails {
display: flex;
align-items: center;
gap: var(--cpd-space-2x, 8px);
margin-top: -2px;
}
.senderDetails .slotAvatar {
position: relative;
inset: auto;
flex: 0 0 auto;
}
.senderDetails .slotSender {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 0;
margin-inline-start: 0;
line-height: normal;
}
.senderDetails .slotTimestamp {
position: initial;
inset: auto;
width: unset;
margin-inline-start: auto;
text-align: initial;
}
.line {
margin-inline-end: 0;
padding: 0;
border-radius: 0;
}
}
@@ -0,0 +1,259 @@
/* Story-only timeline context. The hierarchy mirrors RoomView/ScrollPanel. */
.storySurface {
width: min(100%, 680px);
min-height: 220px;
margin: 0;
padding: 24px;
list-style: none;
background: var(--cpd-color-bg-canvas-default, #fff);
color: var(--cpd-color-text-primary, #17191c);
}
.debugFrame {
position: relative;
}
/* Keep the bubble hover layer above the Storybook surface while keeping it
behind the tile content. */
.storyEventTile.storyLayoutBubble {
isolation: isolate;
}
[data-story-hovered="true"] {
outline: 1px dashed var(--cpd-color-border-focused, #5e7cff);
outline-offset: 2px;
}
.debugTooltip {
position: fixed;
z-index: 1000;
inset-block-end: 12px;
inset-inline-start: 12px;
max-width: min(80vw, 360px);
padding: 4px 8px;
border: 1px solid var(--cpd-color-border-subtle, #c9ced4);
border-radius: 4px;
color: var(--cpd-color-text-primary, #17191c);
background: var(--cpd-color-bg-canvas-default, #fff);
box-shadow: 0 2px 8px rgb(0 0 0 / 18%);
font:
12px/1.3 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
pointer-events: none;
}
.timeline,
.scrollPanel,
.messageListWrapper {
display: flex;
min-width: 0;
width: 100%;
flex-direction: column;
}
.messageListWrapper {
position: relative;
justify-content: flex-end;
}
.messageList {
display: flex;
min-width: 0;
width: 100%;
flex-direction: column;
justify-content: flex-end;
box-sizing: border-box;
margin: 0;
padding: var(--RoomView_MessageList-padding, 18px);
list-style: none;
}
.messageList > li {
clear: both;
}
/* Let the story-only slot labels escape the notification metadata row. */
.detailsContainer {
overflow: visible !important;
}
.line {
/* Marker class used by state stories to style the shared line slot. */
}
.threadListInfo {
display: flex;
position: relative;
align-items: center;
margin-top: var(--cpd-space-2x, 8px);
font: var(--cpd-font-body-sm-regular);
}
.threadListIcon {
display: inline-block;
flex: 0 0 18px;
width: 18px;
height: 18px;
color: var(--cpd-color-icon-secondary, #626a73);
}
.threadListReplies {
position: relative;
padding: 0 var(--cpd-space-3x, 12px) 0 var(--cpd-space-2x, 8px);
color: var(--cpd-color-text-secondary, #626a73);
font-weight: var(--cpd-font-weight-semibold, 600);
white-space: nowrap;
}
/* Match the application IRC profile treatment without adding application
selectors to the shared EventTileView stylesheet. */
.ircSender {
width: var(--event-tile-irc-name-width, 80px);
margin-inline-end: 0;
overflow: hidden;
text-align: start;
}
.ircSender :global(.mx_DisambiguatedProfile_displayName) {
display: inline-block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
}
.ircSender :global(.mx_DisambiguatedProfile_mxid) {
visibility: collapse;
margin-inline-start: 0;
padding-inline: 5px;
}
.replyAuthor,
.roomLabel {
display: inline-flex;
align-items: center;
color: var(--cpd-color-text-secondary, #626a73);
font-size: 12px;
}
.notificationDot {
display: block;
flex: 0 0 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--cpd-color-icon-success-primary, #0d9f6e);
}
.receiptAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: content-box;
position: absolute;
top: 0;
width: 14px;
height: 14px;
border: 1px solid var(--cpd-color-bg-canvas-default, #fff);
border-radius: 50%;
color: #fff;
background: #526b89;
font-size: 8px;
line-height: 14px;
font-weight: 700;
}
.receiptAvatar:first-child {
inset-inline-start: 0;
z-index: 1;
}
.receiptAvatar + .receiptAvatar {
inset-inline-start: 10px;
background: #7e5c96;
}
.readReceiptGroup {
position: relative;
display: inline-block;
z-index: 1;
user-select: none;
}
.readReceiptButton {
display: inline-flex;
flex-direction: row;
align-items: stretch;
height: 16px;
padding: 4px;
border: 0;
border-radius: 6px;
background: transparent;
box-sizing: content-box;
appearance: none;
margin: 0;
font: inherit;
line-height: inherit;
cursor: pointer;
}
.readReceiptButton:hover {
background: var(--cpd-color-bg-subtle-primary, #f0f1f2);
}
.readReceiptContainer {
position: relative;
display: block;
width: 24px;
height: 100%;
}
.body {
max-width: 560px;
padding-block: 4px;
line-height: 1.45;
}
.replyChain {
display: grid;
gap: 2px;
max-width: 520px;
margin: 0;
padding-inline-start: 10px;
border-inline-start: 2px solid var(--username-color, #617b9b);
border-radius: 2px;
color: var(--cpd-color-text-secondary, #626a73);
font-size: 13px;
}
.replyAuthor {
font-weight: 600;
}
.storyReactions {
display: flex;
}
.storyEventTile.storyOwnEvent.storyLayoutBubble .storyReactions {
justify-content: flex-end;
}
.storyEventTile.storyOwnEvent.storyLayoutBubble .storyReactions > :last-child {
order: -1;
}
.contextMenu {
display: none;
}
.messageList[data-event-density="compact"] {
padding-block: 8px;
}
.messageList[data-event-density="compact"] .body {
padding-block: 2px;
line-height: 1.3;
}
@@ -0,0 +1,657 @@
/*
* 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 from "react";
import classNames from "classnames";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { Avatar } from "@vector-im/compound-web";
import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useMockedViewModel } from "../../../../core/viewmodel";
import { useEventPresentation } from "../../EventPresentation";
import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { EventTileView, type EventTileViewProps } from "./index";
import { E2ePadlock, E2ePadlockIcon } from "./E2ePadlock";
import { DisambiguatedProfileView } from "./DisambiguatedProfile";
import { ActionBarAction, ActionBarView } from "../actions/ActionBarView";
import { MessageTimestampView } from "../timestamp/MessageTimestampView";
import { ReactionsRowView } from "../reactions/ReactionsRow";
import { ReactionsRowButtonView } from "../reactions/ReactionsRowButton";
import { type ReactionsRowButtonTooltipViewModel } from "../reactions/ReactionsRowButtonTooltip";
import {
ThreadSummaryView,
ThreadMessagePreviewView,
type ThreadMessagePreviewViewSnapshot,
} from "./ThreadSummary/ThreadSummaryView";
import styles from "./EventTileView.stories.module.css";
type StoryBoundary = HTMLElement;
const eventTileSlotTestIdPrefix = "event-tile-slot-";
const getBoundaryLabel = (boundary: StoryBoundary): string => {
const storyBoundary = boundary.dataset.storyBoundary;
if (storyBoundary) return storyBoundary;
const testId = boundary.dataset.testid;
if (testId?.startsWith(eventTileSlotTestIdPrefix)) {
return `EventTileView.slots.${testId.slice(eventTileSlotTestIdPrefix.length)}`;
}
if (boundary.classList.contains("storyEventTile")) return "EventTileView";
return "EventTileView.line";
};
const getBoundary = (target: EventTarget | null, root: HTMLElement): StoryBoundary | null => {
if (!(target instanceof HTMLElement)) return null;
const boundary = target.closest<StoryBoundary>(
`[data-story-boundary], [data-testid^="${eventTileSlotTestIdPrefix}"], .storyEventTile, .storyEventLine`,
);
return boundary && root.contains(boundary) ? boundary : null;
};
const StoryDebugFrame = ({ children }: React.PropsWithChildren): React.ReactElement => {
const frameRef = React.useRef<HTMLDivElement>(null);
const activeBoundaryRef = React.useRef<StoryBoundary | null>(null);
const [activeBoundary, setActiveBoundary] = React.useState<StoryBoundary | null>(null);
const clearActiveBoundary = (): void => {
activeBoundaryRef.current?.removeAttribute("data-story-hovered");
activeBoundaryRef.current = null;
setActiveBoundary(null);
};
const updateActiveBoundary = (event: React.PointerEvent<HTMLDivElement>): void => {
const frame = frameRef.current;
if (!frame) return;
const boundary = getBoundary(event.target, frame);
if (boundary === activeBoundaryRef.current) return;
activeBoundaryRef.current?.removeAttribute("data-story-hovered");
boundary?.setAttribute("data-story-hovered", "true");
activeBoundaryRef.current = boundary;
setActiveBoundary(boundary);
};
React.useEffect(() => {
return () => activeBoundaryRef.current?.removeAttribute("data-story-hovered");
}, []);
return (
<div
ref={frameRef}
className={styles.debugFrame}
onPointerMove={updateActiveBoundary}
onPointerLeave={clearActiveBoundary}
>
{children}
{activeBoundary && <output className={styles.debugTooltip}>{getBoundaryLabel(activeBoundary)}</output>}
</div>
);
};
const StoryAvatar = ({
room = false,
label = "A",
size = "30px",
className,
}: {
room?: boolean;
label?: string;
size?: string;
className?: string;
}): React.ReactElement => (
<Avatar
id={room ? "!story-room:example.org" : `@${label.toLowerCase()}:example.org`}
name={getStoryAvatarName(room, label)}
type="round"
size={size}
className={className}
aria-label={room ? "Story room avatar" : `${label} avatar`}
/>
);
function getStoryAvatarName(room: boolean, label: string): string {
if (room) return "Story room";
if (label === "A") return "Alice Example";
return "Bob Example";
}
const StorySender = ({
name = "Alex Example",
id = "@alex:example.org",
className,
}: {
name?: string;
id?: string;
className?: string;
}): React.ReactElement => {
const vm = useMockedViewModel({ displayName: name, displayIdentifier: id, emphasizeDisplayName: true }, {});
return <DisambiguatedProfileView vm={vm} className={className} />;
};
const createStorySender = (
isOwnEvent: boolean,
layout: EventTileViewProps["root"]["layout"],
showSenderAndAvatar: boolean,
): React.ReactElement | undefined => {
if (!showSenderAndAvatar) return undefined;
const name = isOwnEvent ? "Alice" : "Bob";
const id = isOwnEvent ? "@alice:example.org" : "@bob:example.org";
const className = layout === "irc" ? styles.ircSender : undefined;
return <StorySender name={name} id={id} className={className} />;
};
const createStoryAvatar = (
isOwnEvent: boolean,
layout: EventTileViewProps["root"]["layout"],
showSenderAndAvatar: boolean,
): React.ReactElement | undefined => {
if (!showSenderAndAvatar) return undefined;
const label = isOwnEvent ? "A" : "B";
const size = layout === "irc" ? "14px" : "30px";
return <StoryAvatar label={label} size={size} />;
};
const StoryTimestamp = ({
className,
visible = true,
}: {
className?: string;
visible?: boolean;
}): React.ReactElement | null => {
const vm = useMockedViewModel(
{ ts: "12:34", tsSentAt: "Tuesday, 4 August 2026 at 12:34", inhibitTooltip: true },
{},
);
return visible ? <MessageTimestampView vm={vm} className={className} /> : null;
};
const StoryBody = (): React.ReactElement => (
<div className={styles.body}>
<div>Here is a realistic event tile body with enough text to show the available width.</div>
<div>This second line makes wrapping and vertical rhythm visible in Storybook.</div>
</div>
);
const StoryReplyChain = (): React.ReactElement => (
<blockquote className={styles.replyChain}>
<span className={styles.replyAuthor}>Taylor Example</span>
<span>Earlier message quoted in this reply.</span>
</blockquote>
);
const StoryActionBar = (): React.ReactElement | null => {
const vm = useMockedViewModel(
{
actions: [ActionBarAction.React, ActionBarAction.Reply, ActionBarAction.Options],
presentation: "icon" as const,
isDownloadEncrypted: false,
isDownloadLoading: false,
isPinned: false,
isQuoteExpanded: false,
isThreadReplyAllowed: true,
},
{ onReactionsClick: fn(), onReplyClick: fn(), onOptionsClick: fn() },
);
return <ActionBarView vm={vm} />;
};
const useStoryReactionTooltipVm = (caption: string): ReactionsRowButtonTooltipViewModel =>
useMockedViewModel({ formattedSenders: "Alice Example and Bob Example", caption }, {});
const StoryFooter = (): React.ReactElement => {
const vm = useMockedViewModel(
{
ariaLabel: "Reactions",
isVisible: true,
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
},
{ onAddReactionClick: fn(), onAddReactionContextMenu: fn() },
);
const thumbsUpVm = useMockedViewModel(
{
"content": "👍",
"count": 2,
"isSelected": false,
"aria-label": "Thumbs up, 2 reactions",
"tooltipVm": useStoryReactionTooltipVm("Thumbs up"),
},
{ onClick: fn() },
);
const heartVm = useMockedViewModel(
{
"content": "❤️",
"count": 1,
"isSelected": true,
"aria-label": "Red heart, 1 reaction",
"tooltipVm": useStoryReactionTooltipVm("Red heart"),
},
{ onClick: fn() },
);
return (
<ReactionsRowView vm={vm} className={styles.storyReactions}>
<ReactionsRowButtonView vm={thumbsUpVm} />
<ReactionsRowButtonView vm={heartVm} />
</ReactionsRowView>
);
};
const storyThreadPreview: ThreadMessagePreviewViewSnapshot = {
isVisible: true,
avatar: {
id: "@alice:example.org",
name: "Alice Example",
label: "Alice Example avatar",
},
showDisplayName: true,
senderName: "Alice Example",
previewContent: "Can you review the draft?",
previewTooltip: "Can you review the draft?",
};
const StoryThreadInfo = (): React.ReactElement => {
const previewVm = useMockedViewModel(storyThreadPreview, {});
const threadSummaryVm = useMockedViewModel(
{
isVisible: true,
replyCountLabel: "3 replies",
openThreadLabel: "Open thread",
notificationIndicator: undefined,
narrow: false,
previewVm,
},
{ onClick: fn() },
);
return <ThreadSummaryView vm={threadSummaryVm} />;
};
/** The ThreadsList view uses the compact inline replies preview, not ThreadSummaryView. */
const StoryThreadListInfo = (): React.ReactElement => {
const previewVm = useMockedViewModel(storyThreadPreview, {});
return (
<div className={styles.threadListInfo}>
<ThreadsIcon className={styles.threadListIcon} />
<span className={styles.threadListReplies}>3</span>
<ThreadMessagePreviewView vm={previewVm} />
</div>
);
};
const StoryReceipt = (): React.ReactElement => (
<span className={styles.receipt}>
<span className={styles.readReceiptGroup}>
<button type="button" className={styles.readReceiptButton} aria-label="Read by Alex and Taylor">
<span className={styles.readReceiptContainer} aria-hidden="true">
<span className={styles.receiptAvatar}>T</span>
<span className={styles.receiptAvatar}>A</span>
</span>
</button>
</span>
</span>
);
const StoryPadlock = (): React.ReactElement => <E2ePadlock icon={E2ePadlockIcon.Normal} title="End-to-end encrypted" />;
const StoryContextMenu = (): React.ReactElement => <span className={styles.contextMenu}></span>;
const TimelineStoryFrame = ({
density,
layout,
children,
}: React.PropsWithChildren<{ density: string; layout: string }>): React.ReactElement => (
<StoryDebugFrame>
<div className={styles.storySurface} data-story-boundary="Timeline">
<div className={styles.timeline} data-story-boundary="RoomView.timeline" data-event-layout={layout}>
<div className={styles.scrollPanel} data-story-boundary="ScrollPanel">
<div className={styles.messageListWrapper} data-story-boundary="messageListWrapper">
<ol
className={styles.messageList}
data-story-boundary="RoomView.MessageList"
data-event-density={density}
>
{children}
</ol>
</div>
</div>
</div>
</div>
</StoryDebugFrame>
);
const baseRoot: EventTileViewProps["root"] = {
id: "event-tile-story-line",
ariaLive: "off",
scrollToken: "event-tile-story",
permalink: "https://example.org/event-tile-story",
eventId: "$event-tile-story",
layout: "group",
shape: "Room",
state: { isOwnEvent: false, hasReply: true },
};
const roomSlots: EventTileViewProps["slots"] = {
sender: <StorySender />,
avatar: <StoryAvatar />,
body: <StoryBody />,
timestamp: <StoryTimestamp />,
padlock: <StoryPadlock />,
replyChain: <StoryReplyChain />,
actionBar: <StoryActionBar />,
footer: <StoryFooter />,
threadInfo: <StoryThreadInfo />,
receipt: <StoryReceipt />,
contextMenu: <StoryContextMenu />,
};
type EventTileStoryProps = Omit<EventTileViewProps, "root"> & {
shape: EventTileViewProps["root"]["shape"];
state?: Partial<EventTileViewProps["root"]["state"]>;
roomMessages?: "boundaries" | "alice";
};
const createStoryTimestamp = (
layout: EventTileViewProps["root"]["layout"],
isLast: boolean,
showActionBar: boolean,
): React.ReactElement | undefined => {
const showTimestamp = layout === "irc" || isLast || showActionBar;
return showTimestamp ? <StoryTimestamp visible={showTimestamp} /> : undefined;
};
const createRoomStorySlots = ({
isOwnEvent,
slots,
sender,
avatar,
timestamp,
showActionBar,
}: {
isOwnEvent: boolean;
slots: EventTileViewProps["slots"];
sender?: React.ReactNode;
avatar?: React.ReactNode;
timestamp?: React.ReactNode;
showActionBar: boolean;
}): EventTileViewProps["slots"] => {
const baseSlots = isOwnEvent ? slots : { body: slots.body };
return {
...baseSlots,
sender,
avatar,
timestamp,
actionBar: showActionBar ? slots.actionBar : undefined,
};
};
const createPreviewStorySlots = ({
shape,
slots,
showActionBar,
}: {
shape: EventTileViewProps["root"]["shape"];
slots: EventTileViewProps["slots"];
showActionBar: boolean;
}): EventTileViewProps["slots"] => {
const threadInfo = shape === "Thread" ? undefined : slots.threadInfo;
return {
...slots,
actionBar: showActionBar ? slots.actionBar : undefined,
// The application Thread rendering branch places no thread-info slot.
threadInfo,
};
};
function EventTileViewStoryContent({
shape,
state,
roomMessages = "boundaries",
...props
}: EventTileStoryProps): React.ReactElement {
const { layout, density } = useEventPresentation();
const [tileInteractions, setTileInteractions] = React.useState<
Record<string, { hovered: boolean; focused: boolean }>
>({});
const updateTileInteraction = (suffix: string, update: Partial<{ hovered: boolean; focused: boolean }>): void => {
setTileInteractions((current) => ({
...current,
[suffix]: {
...(current[suffix] ?? { hovered: false, focused: false }),
...update,
},
}));
};
const renderTile = (
isOwnEvent: boolean,
suffix: string,
boundaryState: Partial<EventTileViewProps["root"]["state"]> = {},
isLast = false,
): React.ReactElement => {
const tileState = { ...boundaryState, ...state };
const interaction = tileInteractions[suffix] ?? { hovered: false, focused: false };
const showActionBar = interaction.hovered || interaction.focused;
const timestamp = createStoryTimestamp(layout, isLast, showActionBar);
const showSenderAndAvatar = layout === "irc" || !tileState.continuation;
const sender = createStorySender(isOwnEvent, layout, showSenderAndAvatar);
const avatar = createStoryAvatar(isOwnEvent, layout, showSenderAndAvatar);
const slots =
shape === "Room"
? createRoomStorySlots({
isOwnEvent,
slots: props.slots,
sender,
avatar,
timestamp,
showActionBar,
})
: createPreviewStorySlots({ shape, slots: props.slots, showActionBar });
return (
<EventTileView
key={suffix}
{...props}
classNames={{
...props.classNames,
root: classNames(
props.classNames?.root,
"storyEventTile",
styles.storyEventTile,
isOwnEvent && styles.storyOwnEvent,
layout === "bubble" && styles.storyLayoutBubble,
),
line: classNames(props.classNames?.line, "storyEventLine"),
}}
slots={slots}
root={{
...baseRoot,
id: `${baseRoot.id}-${suffix}`,
scrollToken: `${baseRoot.scrollToken}-${suffix}`,
eventId: `${baseRoot.eventId}-${suffix}`,
layout,
shape,
state: {
...baseRoot.state,
...tileState,
isOwnEvent,
},
}}
onMouseEnter={(event) => {
props.onMouseEnter?.(event);
updateTileInteraction(suffix, { hovered: true });
}}
onMouseLeave={(event) => {
props.onMouseLeave?.(event);
updateTileInteraction(suffix, { hovered: false });
}}
onFocus={(event) => {
props.onFocus?.(event);
updateTileInteraction(suffix, { focused: true });
}}
onBlur={(event) => {
props.onBlur?.(event);
if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) {
updateTileInteraction(suffix, { focused: false });
}
}}
/>
);
};
const renderRoomTiles = (): React.ReactNode => {
if (roomMessages === "alice") {
return renderTile(true, "alice-single", { continuation: false, lastInSection: true }, true);
}
return (
<>
{renderTile(false, "bob-first", { continuation: false, lastInSection: false })}
{renderTile(false, "bob-middle", { continuation: true, lastInSection: false })}
{renderTile(false, "bob-last", { continuation: true, lastInSection: true })}
{renderTile(true, "alice-single", { continuation: false, lastInSection: true }, true)}
</>
);
};
const tiles = shape === "Room" ? renderRoomTiles() : renderTile(false, "event");
return (
<TimelineStoryFrame density={density} layout={layout}>
{tiles}
</TimelineStoryFrame>
);
}
const EventTileViewStoryImpl = (props: EventTileStoryProps): React.ReactElement => (
<EventTileViewStoryContent {...props} />
);
const EventTileViewStory = withViewDocs(EventTileViewStoryImpl, EventTileView);
const eventTileStoryDefaults = {
classNames: { details: styles.detailsContainer, line: styles.line },
onClick: fn(),
onContextMenu: fn(),
onPermalinkClick: fn(),
onPermalinkContextMenu: fn(),
slots: roomSlots,
};
const meta = {
title: "Timeline/EventTileView",
component: EventTileViewStory,
tags: ["autodocs"],
render: (args) => <EventTileViewStory {...args} />,
argTypes: {
shape: {
control: "select",
options: ["Room", "Thread", "ThreadsList", "File", "Notification", "Search", "Pinned"],
},
classNames: { table: { disable: true } },
state: {
control: "object",
},
onMouseEnter: { table: { disable: true } },
onMouseLeave: { table: { disable: true } },
onFocus: { table: { disable: true } },
onBlur: { table: { disable: true } },
onClick: { table: { disable: true } },
onContextMenu: { table: { disable: true } },
onPermalinkClick: { table: { disable: true } },
onPermalinkContextMenu: { table: { disable: true } },
refs: { table: { disable: true } },
slots: { table: { category: "Slots" } },
},
args: {
shape: "Room",
state: {},
...eventTileStoryDefaults,
},
} satisfies Meta<typeof EventTileViewStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Room: Story = {};
export const ThreadsList: Story = {
args: {
shape: "ThreadsList",
slots: {
sender: <StorySender />,
avatar: <StoryAvatar />,
body: <StoryBody />,
timestamp: <StoryTimestamp />,
notificationBadge: <span className={styles.notificationDot} aria-hidden="true" />,
threadInfo: <StoryThreadListInfo />,
actionBar: <StoryActionBar />,
},
},
};
export const Thread: Story = {
args: {
shape: "Thread",
},
};
export const Notification: Story = {
args: {
shape: "Notification",
slots: {
sender: <StorySender />,
body: <StoryBody />,
timestamp: <StoryTimestamp />,
roomAvatar: <StoryAvatar room size="28px" />,
notificationRoomLabel: <span className={styles.roomLabel}>in Example room</span>,
notificationBadge: <span className={styles.notificationDot} aria-hidden="true" />,
threadInfo: <StoryThreadListInfo />,
receipt: <StoryReceipt />,
},
},
};
export const File: Story = {
args: {
shape: "File",
slots: {
sender: <StorySender />,
avatar: <StoryAvatar />,
timestamp: <StoryTimestamp />,
body: <StoryBody />,
contextMenu: <StoryContextMenu />,
},
},
};
export const Highlighted: Story = {
args: {
shape: "Thread",
state: { highlighted: true },
slots: {
sender: <StorySender />,
avatar: <StoryAvatar />,
timestamp: <StoryTimestamp />,
body: <StoryBody />,
},
},
};
export const Selected: Story = {
args: {
shape: "Thread",
state: { selected: true },
slots: {
sender: <StorySender />,
avatar: <StoryAvatar />,
timestamp: <StoryTimestamp />,
body: <StoryBody />,
},
},
};
@@ -0,0 +1,520 @@
/*
* 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);
});
});
@@ -0,0 +1,234 @@
/*
* 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, { type JSX } from "react";
import classNames from "classnames";
import type { EventTileViewClassNames, EventTileViewProps, EventTileViewSlots } from "./EventTileView.types";
import styles from "./EventTileView.module.css";
type EventTileSlotName = keyof EventTileViewSlots;
/**
* Renders the common EventTile root and event-line structure.
*
* Rendering-mode branches own the timeline-specific slot placement. The
* application supplies render-ready content; this component owns the shared
* structure, slot boundaries, placement classes, and root behavior.
*/
export function EventTileView({
root,
slots,
classNames: classNameOverrides,
refs,
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
onClick,
onContextMenu,
onPermalinkClick,
onPermalinkContextMenu,
}: Readonly<EventTileViewProps>): JSX.Element {
const Root = root.as ?? "li";
const renderSlot = (slotName: EventTileSlotName, content: React.ReactNode = slots[slotName]): React.ReactNode => {
if (content === null || content === undefined || typeof content === "boolean") return null;
const slotConfig: Record<EventTileSlotName, { style: string; className: keyof EventTileViewClassNames }> = {
avatar: { style: styles.slotAvatar, className: "slotAvatar" },
sender: { style: styles.slotSender, className: "slotSender" },
body: { style: styles.slotBody, className: "slotBody" },
timestamp: { style: styles.slotTimestamp, className: "slotTimestamp" },
padlock: { style: styles.slotPadlock, className: "slotPadlock" },
replyChain: { style: styles.slotReplyChain, className: "slotReplyChain" },
actionBar: { style: styles.slotActionBar, className: "slotActionBar" },
footer: { style: styles.slotFooter, className: "slotFooter" },
threadInfo: { style: styles.slotThreadInfo, className: "slotThreadInfo" },
receipt: { style: styles.slotReceipt, className: "slotReceipt" },
roomAvatar: { style: styles.slotAvatar, className: "slotAvatar" },
notificationRoomLabel: { style: styles.slotNotificationRoomLabel, className: "slotNotificationRoomLabel" },
notificationBadge: { style: styles.slotNotificationBadge, className: "slotNotificationBadge" },
contextMenu: { style: styles.slotContextMenu, className: "slotContextMenu" },
};
const { style, className } = slotConfig[slotName];
return (
<div
key={slotName}
data-testid={`event-tile-slot-${slotName}`}
className={classNames(style, classNameOverrides?.[className])}
>
{content}
</div>
);
};
const renderSlots = (...slotNames: EventTileSlotName[]): React.ReactNode =>
slotNames.map((slotName) => renderSlot(slotName));
const renderRoot = (
children: React.ReactNode,
rootClickHandler: React.MouseEventHandler<HTMLElement> | undefined,
rootTabIndex?: number,
): JSX.Element => (
<Root
ref={refs?.root}
className={classNames(styles.root, classNameOverrides?.root, {
[styles.stateOwnEvent]: root.state.isOwnEvent,
[styles.stateHighlighted]: root.state?.highlighted,
[styles.stateSelected]: root.state?.selected,
[styles.stateEditing]: root.state?.editing,
[styles.stateContinuation]: root.state?.continuation,
[styles.stateLastInSection]: root.state?.lastInSection,
[styles.layoutGroup]: root.layout === "group",
[styles.layoutBubble]: root.layout === "bubble",
[styles.layoutIrc]: root.layout === "irc",
[styles.shapeThread]: root.shape === "Thread",
[styles.shapeThreadsList]: root.shape === "ThreadsList",
[styles.shapeFile]: root.shape === "File",
[styles.shapeNotification]: root.shape === "Notification",
})}
aria-live={root.ariaLive}
aria-atomic={true}
data-scroll-tokens={root.scrollToken}
data-event-id={root.eventId}
tabIndex={rootTabIndex}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onBlur}
onClick={rootClickHandler}
>
{children}
</Root>
);
// Thread view: sender details, line content, then footer.
if (root.shape === "Thread") {
return renderRoot(
<>
<div className={classNames(styles.senderDetails, classNameOverrides?.senderDetails)}>
{renderSlots("avatar", "sender")}
</div>
<div
id={root.id}
className={classNames(styles.line, classNameOverrides?.line)}
onContextMenu={onContextMenu}
>
{renderSlots("contextMenu", "replyChain", "body", "actionBar", "timestamp", "receipt")}
</div>
{renderSlot("footer")}
</>,
undefined,
);
}
// Notification view: metadata, room avatar, and preview content.
if (root.shape === "Notification") {
return renderRoot(
<>
<div className={classNames(styles.details, classNameOverrides?.details)}>
{renderSlots("sender", "notificationRoomLabel", "timestamp", "notificationBadge")}
</div>
{slots.roomAvatar ? renderSlot("roomAvatar") : renderSlot("avatar")}
<div className={classNames(styles.line, classNameOverrides?.line)} id={root.id}>
{renderSlots("body", "threadInfo")}
</div>
{renderSlot("receipt")}
</>,
onClick,
-1,
);
}
// ThreadsList view: metadata, avatar, preview content, and action bar.
if (root.shape === "ThreadsList") {
return renderRoot(
<>
<div className={classNames(styles.details, classNameOverrides?.details)}>
{renderSlots("sender", "notificationRoomLabel", "timestamp", "notificationBadge")}
</div>
{renderSlot("avatar")}
<div className={classNames(styles.line, classNameOverrides?.line)} id={root.id}>
{renderSlots("body", "threadInfo")}
</div>
{renderSlot("actionBar")}
{renderSlot("receipt")}
</>,
onClick,
-1,
);
}
// File view: permalink-wrapped sender details followed by the file body.
if (root.shape === "File") {
return renderRoot(
<>
<a
className={classNames(styles.senderDetailsLink, classNameOverrides?.senderDetailsLink)}
href={root.permalink ?? "#"}
onClick={onPermalinkClick}
>
<div
className={classNames(styles.senderDetails, classNameOverrides?.senderDetails)}
onContextMenu={onPermalinkContextMenu}
>
{renderSlots("avatar", "sender", "timestamp")}
</div>
</a>
<div
id={root.id}
className={classNames(styles.line, classNameOverrides?.line)}
onContextMenu={onContextMenu}
>
{renderSlots("contextMenu", "body")}
</div>
</>,
undefined,
);
}
// Default shape: Pinned, Room, Search
// IRC layout: the leading metadata slots precede the line content.
if (root.layout === "irc") {
return renderRoot(
<>
{renderSlots("padlock", "timestamp", "avatar", "sender")}
<div
id={root.id}
className={classNames(styles.line, classNameOverrides?.line)}
onContextMenu={onContextMenu}
>
{renderSlots("contextMenu", "replyChain", "body", "actionBar", "footer", "threadInfo")}
</div>
{renderSlot("receipt")}
</>,
undefined,
-1,
);
}
// Group and bubble layouts: sender details precede the line content.
return renderRoot(
<>
{renderSlots("sender", "avatar")}
<div
id={root.id}
className={classNames(styles.line, classNameOverrides?.line)}
onContextMenu={onContextMenu}
>
{renderSlots("contextMenu", "timestamp", "padlock", "replyChain", "body", "actionBar")}
</div>
{renderSlots("footer", "threadInfo", "receipt")}
</>,
undefined,
-1,
);
}
@@ -0,0 +1,145 @@
/*
* 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 type React from "react";
import type { EventLayout } from "../../EventPresentation/EventPresentation.types";
/** Timeline rendering modes supported by the EventTile shell. */
export type EventTileRenderingMode = "Room" | "Thread" | "ThreadsList" | "File" | "Notification" | "Search" | "Pinned";
/** Conditional state used to derive EventTile root classes. */
export interface EventTileViewRootState {
/** Whether the event belongs to the current user. */
isOwnEvent: boolean;
/** Whether EventTile renders a reply chain. */
hasReply: boolean;
/** Whether the event is highlighted by search or navigation. */
highlighted?: boolean;
/** Whether the event is selected. */
selected?: boolean;
/** Whether the event is currently being edited. */
editing?: boolean;
/** Whether the event continues the previous event block. */
continuation?: boolean;
/** Whether this is the last event in a section. */
lastInSection?: boolean;
}
/** Complete root state consumed by EventTileView. */
export interface EventTileViewRoot {
/** Stable unique id for the component instance. */
id: string;
/** Element used for the EventTile root. Defaults to `li`. */
as?: React.ElementType;
/** Optional aria-live value for the root element. */
ariaLive?: "off";
/** Stable event scroll token. */
scrollToken?: string;
/** Optional permalink used by sender details. */
permalink?: string;
/** Optional event identifier exposed through `data-event-id`. */
eventId?: string;
/** Configured tile layout. */
layout: EventLayout;
/** Timeline rendering mode. */
shape: EventTileRenderingMode;
/** Conditional state classes and styling state. */
state: EventTileViewRootState;
}
/** Optional application CSS class overrides for shell-owned structural elements and slot boundaries. */
export interface EventTileViewClassNames {
root?: string;
line?: string;
details?: string;
senderDetails?: string;
senderDetailsLink?: string;
slotActionBar?: string;
slotAvatar?: string;
slotBody?: string;
slotContextMenu?: string;
slotFooter?: string;
slotNotificationRoomLabel?: string;
slotNotificationBadge?: string;
slotPadlock?: string;
slotReceipt?: string;
slotReplyChain?: string;
slotSender?: string;
slotThreadInfo?: string;
slotTimestamp?: string;
}
/** Render-ready children supplied by the application integration layer. */
export interface EventTileViewSlots {
/** Sender avatar for the current rendering mode. */
avatar?: React.ReactNode;
/** Sender profile/details. */
sender?: React.ReactNode;
/** Main event body. */
body: React.ReactNode;
/** Timestamp rendered by the shell according to the current layout. */
timestamp?: React.ReactNode;
/** Padlock rendered by the shell according to the current layout. */
padlock?: React.ReactNode;
/** Reply chain rendered above the event body. */
replyChain?: React.ReactNode;
/** Action bar rendered by the shell according to the current rendering mode. */
actionBar?: React.ReactNode;
/** Event footer rendered by the shell according to the current layout. */
footer?: React.ReactNode;
/** Thread information rendered by the shell according to the current layout. */
threadInfo?: React.ReactNode;
/** Read receipt or send-state indicator. */
receipt?: React.ReactNode;
/** Room avatar used by notification tiles. */
roomAvatar?: React.ReactNode;
/** Notification room-name label. */
notificationRoomLabel?: React.ReactNode;
/** Unread notification badge. */
notificationBadge?: React.ReactNode;
/** Context menu rendered inside the event line. */
contextMenu?: React.ReactNode;
}
/** DOM refs supplied by the application integration layer. */
export interface EventTileViewRefs {
/** Ref attached to the root element. */
root?: React.Ref<HTMLElement>;
}
/** DOM handlers supplied by the application integration layer. */
export interface EventTileViewHandlers {
/** Root hover start. */
onMouseEnter?: React.MouseEventHandler<HTMLElement>;
/** Root hover end. */
onMouseLeave?: React.MouseEventHandler<HTMLElement>;
/** Root focus entry. */
onFocus?: React.FocusEventHandler<HTMLElement>;
/** Root focus exit. */
onBlur?: React.FocusEventHandler<HTMLElement>;
/** Event click. */
onClick?: React.MouseEventHandler<HTMLElement>;
/** Event context menu. */
onContextMenu?: React.MouseEventHandler<HTMLElement>;
/** Permalink click. */
onPermalinkClick?: React.MouseEventHandler<HTMLAnchorElement>;
/** Permalink context menu. */
onPermalinkContextMenu?: React.MouseEventHandler<HTMLElement>;
}
/** Props for the shared EventTile shell. */
export interface EventTileViewProps extends EventTileViewHandlers {
/** Pure root render state. */
root: EventTileViewRoot;
/** Optional application CSS class overrides for shell-owned elements. */
classNames?: EventTileViewClassNames;
/** Render-ready children supplied by the application layer. Each slot is rendered inside a named shell boundary. */
slots: EventTileViewSlots;
/** DOM refs supplied by the application layer. */
refs?: EventTileViewRefs;
}
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { EventTileView } from "./EventTileView";
export type {
EventTileViewClassNames,
EventTileRenderingMode,
EventTileViewHandlers,
EventTileViewProps,
EventTileViewRefs,
EventTileViewRoot,
EventTileViewRootState,
EventTileViewSlots,
} from "./EventTileView.types";