diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 00a26e38b9..90a5d5b11c 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -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; } diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/file-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/file-auto.png new file mode 100644 index 0000000000..661e95599f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/file-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/highlighted-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/highlighted-auto.png new file mode 100644 index 0000000000..bb0b1e2403 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/highlighted-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/notification-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/notification-auto.png new file mode 100644 index 0000000000..5cccc5ca8f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/notification-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/room-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/room-auto.png new file mode 100644 index 0000000000..b7e99845a4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/room-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/selected-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/selected-auto.png new file mode 100644 index 0000000000..9ffbbb1f8c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/selected-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/thread-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/thread-auto.png new file mode 100644 index 0000000000..90a7b34c46 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/thread-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/threads-list-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/threads-list-auto.png new file mode 100644 index 0000000000..eff18172a6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx/threads-list-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index f483f4273e..194f471630 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/EventPresentation/EventPresentation.types.ts b/packages/shared-components/src/room/timeline/EventPresentation/EventPresentation.types.ts new file mode 100644 index 0000000000..dff7d2c141 --- /dev/null +++ b/packages/shared-components/src/room/timeline/EventPresentation/EventPresentation.types.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/EventPresentation/EventPresentationContext.ts b/packages/shared-components/src/room/timeline/EventPresentation/EventPresentationContext.ts index fdc239a75b..c570c547ec 100644 --- a/packages/shared-components/src/room/timeline/EventPresentation/EventPresentationContext.ts +++ b/packages/shared-components/src/room/timeline/EventPresentation/EventPresentationContext.ts @@ -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 { diff --git a/packages/shared-components/src/room/timeline/EventPresentation/index.ts b/packages/shared-components/src/room/timeline/EventPresentation/index.ts index cd21c7593b..122c2beb59 100644 --- a/packages/shared-components/src/room/timeline/EventPresentation/index.ts +++ b/packages/shared-components/src/room/timeline/EventPresentation/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.module.css new file mode 100644 index 0000000000..e39297987e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.module.css @@ -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; + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.module.css new file mode 100644 index 0000000000..8ea63a9de1 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.module.css @@ -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; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx new file mode 100644 index 0000000000..9997d61057 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.stories.tsx @@ -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( + `[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(null); + const activeBoundaryRef = React.useRef(null); + const [activeBoundary, setActiveBoundary] = React.useState(null); + + const clearActiveBoundary = (): void => { + activeBoundaryRef.current?.removeAttribute("data-story-hovered"); + activeBoundaryRef.current = null; + setActiveBoundary(null); + }; + + const updateActiveBoundary = (event: React.PointerEvent): 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 ( +
+ {children} + {activeBoundary && {getBoundaryLabel(activeBoundary)}} +
+ ); +}; + +const StoryAvatar = ({ + room = false, + label = "A", + size = "30px", + className, +}: { + room?: boolean; + label?: string; + size?: string; + className?: string; +}): React.ReactElement => ( + +); + +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 ; +}; + +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 ; +}; + +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 ; +}; + +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 ? : null; +}; +const StoryBody = (): React.ReactElement => ( +
+
Here is a realistic event tile body with enough text to show the available width.
+
This second line makes wrapping and vertical rhythm visible in Storybook.
+
+); +const StoryReplyChain = (): React.ReactElement => ( +
+ Taylor Example + Earlier message quoted in this reply. +
+); +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 ; +}; + +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 ( + + + + + ); +}; +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 ; +}; + +/** The ThreadsList view uses the compact inline replies preview, not ThreadSummaryView. */ +const StoryThreadListInfo = (): React.ReactElement => { + const previewVm = useMockedViewModel(storyThreadPreview, {}); + + return ( +
+ + 3 + +
+ ); +}; +const StoryReceipt = (): React.ReactElement => ( + + + + + +); +const StoryPadlock = (): React.ReactElement => ; +const StoryContextMenu = (): React.ReactElement => ; + +const TimelineStoryFrame = ({ + density, + layout, + children, +}: React.PropsWithChildren<{ density: string; layout: string }>): React.ReactElement => ( + +
+
+
+
+
    + {children} +
+
+
+
+
+
+); + +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: , + avatar: , + body: , + timestamp: , + padlock: , + replyChain: , + actionBar: , + footer: , + threadInfo: , + receipt: , + contextMenu: , +}; + +type EventTileStoryProps = Omit & { + shape: EventTileViewProps["root"]["shape"]; + state?: Partial; + roomMessages?: "boundaries" | "alice"; +}; + +const createStoryTimestamp = ( + layout: EventTileViewProps["root"]["layout"], + isLast: boolean, + showActionBar: boolean, +): React.ReactElement | undefined => { + const showTimestamp = layout === "irc" || isLast || showActionBar; + return 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 + >({}); + + 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 = {}, + 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 ( + { + 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 ( + + {tiles} + + ); +} + +const EventTileViewStoryImpl = (props: EventTileStoryProps): React.ReactElement => ( + +); + +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) => , + 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; + +export default meta; +type Story = StoryObj; + +export const Room: Story = {}; + +export const ThreadsList: Story = { + args: { + shape: "ThreadsList", + slots: { + sender: , + avatar: , + body: , + timestamp: , + notificationBadge: , + notificationRoomLabel: + shape === "Notification" ? Room : undefined, + timestamp: Timestamp, + notificationBadge: Badge, + roomAvatar: Room avatar, + avatar: Avatar, + body: Body, + threadInfo: Thread info, + actionBar: Action bar, + receipt: Receipt, + }, + })} + />, + ); + const root = container.firstElementChild!; + const details = getByTestId("sender").parentElement?.parentElement; + const line = getByTestId("body").parentElement?.parentElement; + + if (!details || !line) { + throw new Error(`Expected EventTile ${shape} layout elements to be present`); + } + + expect(root).toHaveAttribute("tabindex", "-1"); + expect(root).toHaveClass(shape === "Notification" ? styles.shapeNotification : styles.shapeThreadsList); + expect(details).toContainElement(getByTestId("timestamp")); + expect(details).toHaveClass("legacy-details"); + expect(details).toContainElement(getByTestId("badge")); + expect(line).toContainElement(getByTestId("thread-info")); + expect(getByTestId("receipt").parentElement?.parentElement).toBe(root); + + if (shape === "Notification") { + const avatar = getByTestId("room-avatar").parentElement!; + expect(avatar.parentElement).toBe(root); + expect(avatar).toHaveClass("legacy-avatar"); + expect(queryByTestId("action-bar")).not.toBeInTheDocument(); + } else { + const avatar = getByTestId("avatar").parentElement!; + const actionBar = getByTestId("action-bar").parentElement!; + expect(avatar.parentElement).toBe(root); + expect(actionBar.parentElement).toBe(root); + expect(actionBar).toHaveClass("legacy-thread-action-bar"); + expect(queryByTestId("room-avatar")).not.toBeInTheDocument(); + } + }); + + it("renders the file layout with permalink interactions", () => { + const onPermalinkClick = vi.fn((event: React.MouseEvent) => { + event.preventDefault(); + }); + const onPermalinkContextMenu = vi.fn(); + const { container, getByTestId } = render( + Avatar, + sender: Sender, + timestamp: Timestamp, + body: Body, + }, + })} + />, + ); + const root = container.firstElementChild!; + const link = getByTestId("sender").closest("a"); + const senderDetails = getByTestId("sender").closest(".legacy-sender-details"); + + expect(root).toHaveClass(styles.shapeFile); + + if (!link) { + throw new Error("Expected sender details link"); + } + if (!senderDetails) { + throw new Error("Expected sender details container"); + } + + expect(link).toHaveAttribute("href", renderState.permalink); + expect(link).toHaveClass("legacy-sender-details-link"); + expect(senderDetails).toHaveClass("legacy-sender-details"); + expect(senderDetails).toContainElement(getByTestId("timestamp")); + expect(getByTestId("body").closest("#event-line-1")).toBe(root.lastElementChild); + + fireEvent.click(link); + fireEvent.contextMenu(senderDetails); + expect(onPermalinkClick).toHaveBeenCalledOnce(); + expect(onPermalinkContextMenu).toHaveBeenCalledOnce(); + }); + + it("falls back to a safe file permalink", () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId("body").closest("#event-line-1")?.previousElementSibling).toHaveAttribute("href", "#"); + }); + + it.each(["group", "irc"] as const)("renders the %s timeline layout", (layout) => { + const { container, getByTestId } = render( + Sender, + avatar: Avatar, + timestamp: Timestamp, + padlock: Padlock, + replyChain: Reply chain, + body: Body, + actionBar: Action bar, + footer: Footer, + threadInfo: Thread info, + receipt: Receipt, + contextMenu: Context menu, + }, + })} + />, + ); + const root = container.firstElementChild!; + const line = getByTestId("body").parentElement?.parentElement; + + if (!line) { + throw new Error(`Expected EventTile ${layout} layout line to be present`); + } + + expect(root).toHaveAttribute("tabindex", "-1"); + expect(root).toHaveClass(layout === "irc" ? styles.layoutIrc : styles.layoutGroup); + expect(line).toContainElement(getByTestId("reply-chain")); + expect(line).toContainElement(getByTestId("action-bar")); + + if (layout === "irc") { + expect(root.children[0]).toContainElement(getByTestId("padlock")); + expect(root.children[1]).toContainElement(getByTestId("timestamp")); + expect(root.children[2]).toContainElement(getByTestId("avatar")); + expect(root.children[3]).toContainElement(getByTestId("sender")); + expect(root.children[4]).toBe(line); + expect(root.children[5]).toContainElement(getByTestId("receipt")); + expect(getByTestId("padlock").parentElement?.parentElement).toBe(root); + expect(getByTestId("footer").parentElement?.parentElement).toBe(line); + expect(getByTestId("thread-info").parentElement?.parentElement).toBe(line); + } else { + expect(getByTestId("timestamp").parentElement?.parentElement).toBe(line); + expect(getByTestId("padlock").parentElement?.parentElement).toBe(line); + expect(getByTestId("footer").parentElement?.parentElement).toBe(root); + expect(getByTestId("thread-info").parentElement?.parentElement).toBe(root); + } + + expect(getByTestId("receipt").parentElement?.parentElement).toBe(root); + }); + + it("forwards root and line interactions", () => { + const onClick = vi.fn(); + const onContextMenu = vi.fn(); + const { container, getByTestId } = render(); + + fireEvent.click(container.firstElementChild!); + fireEvent.contextMenu(getByTestId("body").parentElement!); + + expect(onClick).not.toHaveBeenCalled(); + expect(onContextMenu).toHaveBeenCalledOnce(); + + const { container: previewContainer } = render( + , + ); + fireEvent.click(previewContainer.firstElementChild!); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("does not attach a context menu handler to preview lines", () => { + const onContextMenu = vi.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.contextMenu(getByTestId("body").parentElement!); + expect(onContextMenu).not.toHaveBeenCalled(); + }); + + it("forwards the root ref and supports a custom root element", () => { + const rootRef = createRef(); + const { container } = render( + , + ); + + expect(container.firstElementChild?.tagName).toBe("ARTICLE"); + expect(rootRef.current).toBe(container.firstElementChild); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.tsx new file mode 100644 index 0000000000..fe1685f72f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.tsx @@ -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): 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 = { + 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 ( +
+ {content} +
+ ); + }; + + const renderSlots = (...slotNames: EventTileSlotName[]): React.ReactNode => + slotNames.map((slotName) => renderSlot(slotName)); + + const renderRoot = ( + children: React.ReactNode, + rootClickHandler: React.MouseEventHandler | undefined, + rootTabIndex?: number, + ): JSX.Element => ( + + {children} + + ); + + // Thread view: sender details, line content, then footer. + if (root.shape === "Thread") { + return renderRoot( + <> +
+ {renderSlots("avatar", "sender")} +
+
+ {renderSlots("contextMenu", "replyChain", "body", "actionBar", "timestamp", "receipt")} +
+ {renderSlot("footer")} + , + undefined, + ); + } + + // Notification view: metadata, room avatar, and preview content. + if (root.shape === "Notification") { + return renderRoot( + <> +
+ {renderSlots("sender", "notificationRoomLabel", "timestamp", "notificationBadge")} +
+ {slots.roomAvatar ? renderSlot("roomAvatar") : renderSlot("avatar")} +
+ {renderSlots("body", "threadInfo")} +
+ {renderSlot("receipt")} + , + onClick, + -1, + ); + } + + // ThreadsList view: metadata, avatar, preview content, and action bar. + if (root.shape === "ThreadsList") { + return renderRoot( + <> +
+ {renderSlots("sender", "notificationRoomLabel", "timestamp", "notificationBadge")} +
+ {renderSlot("avatar")} +
+ {renderSlots("body", "threadInfo")} +
+ {renderSlot("actionBar")} + {renderSlot("receipt")} + , + onClick, + -1, + ); + } + + // File view: permalink-wrapped sender details followed by the file body. + if (root.shape === "File") { + return renderRoot( + <> + +
+ {renderSlots("avatar", "sender", "timestamp")} +
+
+
+ {renderSlots("contextMenu", "body")} +
+ , + 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")} +
+ {renderSlots("contextMenu", "replyChain", "body", "actionBar", "footer", "threadInfo")} +
+ {renderSlot("receipt")} + , + undefined, + -1, + ); + } + + // Group and bubble layouts: sender details precede the line content. + return renderRoot( + <> + {renderSlots("sender", "avatar")} +
+ {renderSlots("contextMenu", "timestamp", "padlock", "replyChain", "body", "actionBar")} +
+ {renderSlots("footer", "threadInfo", "receipt")} + , + undefined, + -1, + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.types.ts b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.types.ts new file mode 100644 index 0000000000..b2b5c723d5 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventTileView.types.ts @@ -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; +} + +/** DOM handlers supplied by the application integration layer. */ +export interface EventTileViewHandlers { + /** Root hover start. */ + onMouseEnter?: React.MouseEventHandler; + /** Root hover end. */ + onMouseLeave?: React.MouseEventHandler; + /** Root focus entry. */ + onFocus?: React.FocusEventHandler; + /** Root focus exit. */ + onBlur?: React.FocusEventHandler; + /** Event click. */ + onClick?: React.MouseEventHandler; + /** Event context menu. */ + onContextMenu?: React.MouseEventHandler; + /** Permalink click. */ + onPermalinkClick?: React.MouseEventHandler; + /** Permalink context menu. */ + onPermalinkContextMenu?: React.MouseEventHandler; +} + +/** 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; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/index.ts b/packages/shared-components/src/room/timeline/event-tile/EventTileView/index.ts new file mode 100644 index 0000000000..d6ace3c8ea --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/index.ts @@ -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";