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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -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";
|
||||