Make shared components aware of layout and density settings (#33368)

* Add shared event presentation context

* Add app-web event presentation mapper

* Wire event presentation provider into app timelines

* Add Storybook controls for event layout and density

* Wire compact density through app/web event presentation provider

* Use event presentation density for URL previews

* Move TileErrorView layout to event presentation context

* Minor fix and updated snapshot

* Updated snapshots for url preview group

* Prettier fix

* Restore removed story to fix missing playwright test

* Updates after review comments

* Fix prettier issue
This commit is contained in:
rbondesson 2026-05-06 07:30:46 +02:00 committed by GitHub
parent 7e26c2d144
commit 1f71a3a3fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 379 additions and 209 deletions

View File

@ -34,6 +34,7 @@ import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
import EmptyState from "../views/right_panel/EmptyState";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
interface IProps {
roomId: string;
@ -286,15 +287,17 @@ class FilePanel extends React.Component<IProps, IState> {
>
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
empty={emptyState}
layout={Layout.Group}
/>
<EventPresentationContextProvider layout={Layout.Group}>
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview={false}
onPaginationRequest={this.onPaginationRequest}
empty={emptyState}
layout={Layout.Group}
/>
</EventPresentationContextProvider>
</BaseCard>
</ScopedRoomContextProvider>
);

View File

@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
import { ModuleApi } from "../../modules/Api.ts";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@ -2583,32 +2584,34 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let messagePanel: JSX.Element | undefined;
if (!isRoomEncryptionLoading) {
messagePanel = (
<TimelinePanel
ref={this.gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
}
manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel}
highlightedEventId={highlightedEventId}
eventId={this.state.initialEventId}
eventScrollIntoView={this.state.initialEventScrollIntoView}
eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll}
onEventScrolledIntoView={this.resetJumpToEvent}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview={this.state.showUrlPreview}
className={this.messagePanelClassNames}
membersLoaded={this.state.membersLoaded}
permalinkCreator={this.permalinkCreator}
showReactions={true}
layout={this.state.layout}
editState={this.state.editState}
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity}
/>
<EventPresentationContextProvider layout={this.state.layout}>
<TimelinePanel
ref={this.gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
}
manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel}
highlightedEventId={highlightedEventId}
eventId={this.state.initialEventId}
eventScrollIntoView={this.state.initialEventScrollIntoView}
eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll}
onEventScrolledIntoView={this.resetJumpToEvent}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview={this.state.showUrlPreview}
className={this.messagePanelClassNames}
membersLoaded={this.state.membersLoaded}
permalinkCreator={this.permalinkCreator}
showReactions={true}
layout={this.state.layout}
editState={this.state.editState}
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity}
/>
</EventPresentationContextProvider>
);
}

View File

@ -51,6 +51,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
import Heading from "../views/typography/Heading";
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
interface IProps {
room: Room;
@ -388,32 +389,36 @@ export default class ThreadView extends React.Component<IProps, IState> {
);
}
const layout = this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group;
timeline = (
<>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
<TimelinePanel
key={this.state.thread.id}
ref={this.timelinePanel}
showReadReceipts={this.context.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state.thread.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// ThreadView doesn't support IRC layout at this time
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.props.initialEvent?.getId()}
highlightedEventId={highlightedEventId}
eventScrollIntoView={this.props.initialEventScrollIntoView}
onEventScrolledIntoView={this.resetJumpToEvent}
/>
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
key={this.state.thread.id}
ref={this.timelinePanel}
showReadReceipts={this.context.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={true}
sendReadReceiptOnLoad={true}
timelineSet={this.state.thread.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// ThreadView doesn't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.props.initialEvent?.getId()}
highlightedEventId={highlightedEventId}
eventScrollIntoView={this.props.initialEventScrollIntoView}
onEventScrolledIntoView={this.resetJumpToEvent}
/>
</EventPresentationContextProvider>
</>
);
} else {

View File

@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import Measured from "../elements/Measured";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
interface IProps {
room: Room;
@ -197,6 +198,7 @@ export default class TimelineCard extends React.Component<IProps, IState> {
const myMembership = this.props.room.getMyMembership();
const showComposer = myMembership === KnownMembership.Join;
const layout = this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group;
return (
<ScopedRoomContextProvider
@ -215,27 +217,29 @@ export default class TimelineCard extends React.Component<IProps, IState> {
<Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline">
{jumpToBottom}
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
<EventPresentationContextProvider layout={layout}>
<TimelinePanel
ref={this.timelinePanel}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={true}
manageReadMarkers={false} // No RM support in the TimelineCard
sendReadReceiptOnLoad={true}
timelineSet={this.props.timelineSet}
showUrlPreview={this.context.showUrlPreview}
// The right panel timeline (and therefore threads) don't support IRC layout at this time
layout={layout}
hideThreadedMessages={false}
hidden={false}
showReactions={true}
className="mx_RoomView_messagePanel"
permalinkCreator={this.props.permalinkCreator}
membersLoaded={true}
editState={this.state.editState}
eventId={this.state.initialEventId}
highlightedEventId={highlightedEventId}
onScroll={this.onScroll}
/>
</EventPresentationContextProvider>
</div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}

View File

@ -57,7 +57,6 @@ import {
ReactionsRowButtonView,
ReactionsRowView,
TileErrorView,
type TileErrorViewLayout,
useViewModel,
} from "@element-hq/web-shared-components";
@ -1579,24 +1578,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
*/
interface EventTileErrorFallbackProps {
error: Error;
layout: Layout;
mxEvent: MatrixEvent;
}
function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
function EventTileErrorFallback({ error, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
const developerMode = useSettingValue("developerMode");
const vm = useCreateAutoDisposedViewModel(
() => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }),
);
const vm = useCreateAutoDisposedViewModel(() => new TileErrorViewModel({ error, mxEvent, developerMode }));
useEffect(() => {
vm.setError(error);
}, [error, vm]);
useEffect(() => {
vm.setLayout(layout as TileErrorViewLayout);
}, [layout, vm]);
useEffect(() => {
vm.setDeveloperMode(developerMode);
}, [developerMode, vm]);
@ -1606,7 +1598,6 @@ function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileEr
interface EventTileErrorBoundaryProps {
children: ReactNode;
layout: Layout;
mxEvent: MatrixEvent;
}
@ -1626,13 +1617,7 @@ class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps
public render(): ReactNode {
if (this.state.error) {
return (
<EventTileErrorFallback
error={this.state.error}
layout={this.props.layout}
mxEvent={this.props.mxEvent}
/>
);
return <EventTileErrorFallback error={this.state.error} mxEvent={this.props.mxEvent} />;
}
return this.props.children;
@ -1642,7 +1627,7 @@ class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = (props: EventTileProps): JSX.Element => {
return (
<EventTileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<EventTileErrorBoundary mxEvent={props.mxEvent}>
<UnwrappedEventTile {...props} />
</EventTileErrorBoundary>
);

View File

@ -0,0 +1,50 @@
/*
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, type PropsWithChildren, useMemo } from "react";
import { EventPresentationProvider, type EventLayout, type EventPresentation } from "@element-hq/web-shared-components";
import { Layout } from "../settings/enums/Layout";
import { useSettingValue } from "../hooks/useSettings";
const EVENT_LAYOUT_BY_APP_LAYOUT: Record<Layout, EventLayout> = {
[Layout.Bubble]: "bubble",
[Layout.Group]: "group",
[Layout.IRC]: "irc",
};
function getEventDensity(layout: Layout, useCompactLayout: boolean): EventPresentation["density"] {
return useCompactLayout && layout === Layout.Group ? "compact" : "default";
}
/** Converts app/web layout settings into shared event presentation settings. */
export function getEventPresentation(layout: Layout, useCompactLayout: boolean): EventPresentation {
return {
layout: EVENT_LAYOUT_BY_APP_LAYOUT[layout],
density: getEventDensity(layout, useCompactLayout),
};
}
/** Props for the app/web event presentation context provider. */
export interface EventPresentationContextProviderProps {
/** Layout selected by the app/web surface rendering the timeline. */
layout: Layout;
}
/** Provides shared event presentation using app/web-owned layout settings. */
export function EventPresentationContextProvider({
layout,
children,
}: Readonly<PropsWithChildren<EventPresentationContextProviderProps>>): JSX.Element {
// Compact density is still owned by app/web; this exposes it as shared event presentation.
const useCompactLayout = useSettingValue("useCompactLayout");
const eventLayout = EVENT_LAYOUT_BY_APP_LAYOUT[layout];
const density = getEventDensity(layout, useCompactLayout);
const value = useMemo<EventPresentation>(() => ({ layout: eventLayout, density }), [eventLayout, density]);
return <EventPresentationProvider value={value}>{children}</EventPresentationProvider>;
}

View File

@ -9,7 +9,6 @@ import { type MouseEventHandler } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
BaseViewModel,
type TileErrorViewLayout,
type TileErrorViewSnapshot as TileErrorViewSnapshotInterface,
type TileErrorViewModel as TileErrorViewModelInterface,
} from "@element-hq/web-shared-components";
@ -24,10 +23,6 @@ import BugReportDialog from "../../components/views/dialogs/BugReportDialog";
const TILE_ERROR_BUG_REPORT_LABEL = "react-tile-soft-crash";
export interface TileErrorViewModelProps {
/**
* Layout variant used by the host timeline.
*/
layout: TileErrorViewLayout;
/**
* Event whose tile failed to render.
*/
@ -64,17 +59,15 @@ function getViewSourceCtaLabel(developerMode: boolean): string | undefined {
/**
* ViewModel for the tile error fallback, providing the snapshot shown when a tile fails to render.
*
* The snapshot includes the host timeline layout, the fallback message, the event type,
* and optional bug-report and view-source action labels. The view model also exposes
* click handlers for those actions, opening the bug-report or view-source dialog when
* available.
* The snapshot includes the fallback message, event type, and optional bug-report and
* view-source action labels. The view model also exposes click handlers for those
* actions, opening the bug-report or view-source dialog when available.
*/
export class TileErrorViewModel
extends BaseViewModel<TileErrorViewSnapshotInterface, TileErrorViewModelProps>
implements TileErrorViewModelInterface
{
private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({
layout: props.layout,
message: _t("timeline|error_rendering_message"),
eventType: props.mxEvent.getType(),
bugReportCtaLabel: getBugReportCtaLabel(),
@ -85,11 +78,6 @@ export class TileErrorViewModel
super(props, TileErrorViewModel.computeSnapshot(props));
}
public setLayout(layout: TileErrorViewLayout): void {
this.props.layout = layout;
this.snapshot.merge({ layout });
}
public setError(error: Error): void {
this.props.error = error;
}

View File

@ -20,7 +20,6 @@ import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
import { mediaFromMxc } from "../../customisations/Media";
import PlatformPeg from "../../PlatformPeg";
import { thumbHeight } from "../../ImageUtils";
import SettingsStore from "../../settings/SettingsStore";
import { PosthogAnalytics } from "../../PosthogAnalytics";
const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
@ -239,7 +238,6 @@ export class UrlPreviewGroupViewModel
private readonly client: MatrixClient;
private readonly storageKey: string;
private readonly eventSendTime: number;
private readonly useCompactLayoutSettingWatcher: string;
/**
* Should the URL preview render according to the application.
@ -282,7 +280,6 @@ export class UrlPreviewGroupViewModel
totalPreviewCount: 0,
previewsLimited: true,
overPreviewLimit: false,
compactLayout: SettingsStore.getValue("useCompactLayout"),
});
this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1";
this.urlPreviewVisible = props.visible;
@ -291,15 +288,6 @@ export class UrlPreviewGroupViewModel
this.client = props.client;
this.eventSendTime = props.mxEvent.getTs();
this.onImageClick = props.onImageClicked;
this.useCompactLayoutSettingWatcher = SettingsStore.watchSetting(
"useCompactLayout",
null,
(_setting, _roomid, _level, compactLayout) => {
this.snapshot.merge({
compactLayout: !!compactLayout,
});
},
);
}
/**
@ -383,11 +371,6 @@ export class UrlPreviewGroupViewModel
return result;
}
public dispose(): void {
super.dispose();
SettingsStore.unwatchSetting(this.useCompactLayoutSettingWatcher);
}
private get visibility(): PreviewVisibility {
if (!this.urlPreviewVisible) {
return PreviewVisibility.Hidden;

View File

@ -0,0 +1,57 @@
/*
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 { act, render, screen, waitFor } from "jest-matrix-react";
import { useEventPresentation } from "@element-hq/web-shared-components";
import { Layout } from "../../../src/settings/enums/Layout";
import {
EventPresentationContextProvider,
getEventPresentation,
} from "../../../src/utils/EventPresentationContextProvider";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
const PresentationProbe: React.FC = () => {
const { layout, density } = useEventPresentation();
return <div data-testid="presentation">{`${layout}:${density}`}</div>;
};
describe("EventPresentationContextProvider", () => {
beforeEach(async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, false);
});
it.each([
[Layout.Group, false, { layout: "group", density: "default" }],
[Layout.Group, true, { layout: "group", density: "compact" }],
[Layout.Bubble, false, { layout: "bubble", density: "default" }],
[Layout.Bubble, true, { layout: "bubble", density: "default" }],
[Layout.IRC, false, { layout: "irc", density: "default" }],
[Layout.IRC, true, { layout: "irc", density: "default" }],
])("maps %s with compact=%s", (layout, useCompactLayout, expected) => {
expect(getEventPresentation(layout, useCompactLayout)).toEqual(expected);
});
it("updates provider density when compact layout changes", async () => {
render(
<EventPresentationContextProvider layout={Layout.Group}>
<PresentationProbe />
</EventPresentationContextProvider>,
);
expect(screen.getByTestId("presentation")).toHaveTextContent("group:default");
await act(async () => {
await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true);
});
await waitFor(() => expect(screen.getByTestId("presentation")).toHaveTextContent("group:compact"));
});
});

View File

@ -33,7 +33,6 @@ describe("TileErrorViewModel", () => {
const mxEvent = overrides.mxEvent ?? createEvent();
return new TileErrorViewModel({
layout: "group",
developerMode: true,
error,
mxEvent,
@ -56,7 +55,6 @@ describe("TileErrorViewModel", () => {
const vm = createVm();
expect(vm.getSnapshot()).toEqual({
layout: "group",
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
@ -78,17 +76,6 @@ describe("TileErrorViewModel", () => {
expect(vm.getSnapshot().viewSourceCtaLabel).toBeUndefined();
});
it("updates the layout when the host timeline layout changes", () => {
const vm = createVm();
const listener = jest.fn();
vm.subscribe(listener);
vm.setLayout("bubble");
expect(vm.getSnapshot().layout).toBe("bubble");
expect(listener).toHaveBeenCalledTimes(1);
});
it("guards setters against unchanged values", () => {
const error = new Error("Boom");
const mxEvent = createEvent();
@ -98,7 +85,6 @@ describe("TileErrorViewModel", () => {
vm.setDeveloperMode(true);
vm.setError(error);
vm.setLayout("group");
expect(listener).not.toHaveBeenCalled();
});

View File

@ -75,7 +75,6 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses
exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [
{
@ -96,7 +95,6 @@ exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the sa
exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
@ -106,7 +104,6 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 2`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [
{
@ -127,7 +124,6 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
@ -137,7 +133,6 @@ exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,
@ -147,7 +142,6 @@ exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [
{
@ -168,7 +162,6 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [
{
@ -197,7 +190,6 @@ exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [
{
@ -218,7 +210,6 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
exports[`UrlPreviewGroupViewModel should return no previews by default 1`] = `
{
"compactLayout": false,
"overPreviewLimit": false,
"previews": [],
"previewsLimited": true,

View File

@ -1,4 +1,11 @@
import type { ArgTypes, Preview, Decorator, ReactRenderer, StrictArgs } from "@storybook/react-vite";
/*
* 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 { ArgTypes, Decorator, Preview, ReactRenderer, StrictArgs } from "@storybook/react-vite";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
@ -7,10 +14,11 @@ import "@fontsource/inter/700.css";
import "./compound.css";
import "./preview.css";
import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/core/i18n/i18n";
import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf";
import { I18nApi, I18nContext } from "../src";
import type { StoryContext } from "storybook/internal/csf";
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
import { setLanguage } from "../src/core/i18n/i18n";
export const globalTypes = {
theme: {
@ -32,9 +40,36 @@ export const globalTypes = {
name: "Language",
description: "Global language for components",
},
eventLayout: {
name: "Event layout",
description: "Global event layout for timeline components",
toolbar: {
icon: "component",
title: "Event layout",
items: [
{ title: "Group", value: "group" },
{ title: "Bubble", value: "bubble" },
{ title: "IRC", value: "irc" },
],
},
},
eventDensity: {
name: "Event density",
description: "Global event density for timeline components",
toolbar: {
icon: "listunordered",
title: "Event density",
items: [
{ title: "Default", value: "default" },
{ title: "Compact", value: "compact" },
],
},
},
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
} satisfies ArgTypes;
@ -83,9 +118,28 @@ const withI18nProvider: Decorator = (Story) => {
);
};
const preview: Preview = {
const withEventPresentationProvider: Decorator = (Story, context) => {
return (
<EventPresentationProvider
value={{
layout: context.globals.eventLayout as EventLayout,
density: context.globals.eventDensity as EventDensity,
}}
>
<Story />
</EventPresentationProvider>
);
};
const preview = {
tags: ["autodocs", "snapshot"],
decorators: [withThemeProvider, withTooltipProvider, withI18nProvider],
initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
parameters: {
options: {
storySort: {
@ -101,6 +155,6 @@ const preview: Preview = {
},
},
loaders: [languageLoader],
};
} satisfies Preview;
export default preview;

View File

@ -14,6 +14,7 @@ export * from "./core/roving";
export * from "./room/composer/Banner";
export * from "./crypto/SasEmoji";
export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/EventPresentation";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView";

View File

@ -0,0 +1,39 @@
/*
* 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 { 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";
/** Presentation settings that shared event/timeline components can adapt to. */
export interface EventPresentation {
/** Layout family used for event rendering. */
layout: EventLayout;
/** Spacing density used within the layout. */
density: EventDensity;
}
/** Default event presentation used when no provider is present. */
export const DEFAULT_EVENT_PRESENTATION: EventPresentation = {
layout: "group",
density: "default",
};
const EventPresentationContext = createContext<EventPresentation>(DEFAULT_EVENT_PRESENTATION);
EventPresentationContext.displayName = "EventPresentationContext";
/** Provides event presentation settings to shared event/timeline components. */
export const EventPresentationProvider = EventPresentationContext.Provider;
/** Returns the current event presentation settings. */
export function useEventPresentation(): EventPresentation {
return useContext(EventPresentationContext);
}

View File

@ -0,0 +1,15 @@
/*
* 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 {
DEFAULT_EVENT_PRESENTATION,
EventPresentationProvider,
useEventPresentation,
type EventDensity,
type EventLayout,
type EventPresentation,
} from "./EventPresentationContext";

View File

@ -11,6 +11,7 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { EventPresentationProvider } from "../../../EventPresentation";
import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView";
type WrapperProps = TileErrorViewSnapshot &
@ -49,7 +50,6 @@ const meta = {
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source",
layout: "group",
},
} satisfies Meta<typeof TileErrorViewWrapper>;
@ -59,9 +59,13 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const BubbleLayout: Story = {
args: {
layout: "bubble",
},
decorators: [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "bubble", density: "default" }}>
<Story />
</EventPresentationProvider>
),
],
};
export const WithoutActions: Story = {

View File

@ -58,7 +58,6 @@ describe("TileErrorView", () => {
const vm = new TestTileErrorViewModel(
{
layout: "group",
message: "Can't load this message",
eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs",
@ -81,7 +80,6 @@ describe("TileErrorView", () => {
it("applies a custom className to the root element", () => {
const vm = new MockViewModel<TileErrorViewSnapshot>({
layout: "group",
message: "Can't load this message",
}) as TileErrorViewModel;

View File

@ -10,44 +10,30 @@ import classNames from "classnames";
import { Button } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useEventPresentation } from "../../../EventPresentation";
import styles from "./TileErrorView.module.css";
export type TileErrorViewLayout = "bubble" | "group" | "irc";
/** Snapshot data for rendering an event tile error fallback. */
export interface TileErrorViewSnapshot {
/**
* Layout variant used by the host timeline.
*/
layout?: TileErrorViewLayout;
/**
* Primary fallback text shown when a tile fails to render.
*/
/** Primary fallback text shown when a tile fails to render. */
message: string;
/**
* Optional event type appended to the fallback text.
*/
/** Optional event type appended to the fallback text. */
eventType?: string;
/**
* Optional label for the bug-report action button.
*/
/** Optional label for the bug-report action button. */
bugReportCtaLabel?: string;
/**
* Optional label for the view-source action.
*/
/** Optional label for the view-source action. */
viewSourceCtaLabel?: string;
}
/** User actions emitted by the tile error fallback. */
export interface TileErrorViewActions {
/**
* Invoked when the bug-report button is clicked.
*/
/** Invoked when the bug-report button is clicked. */
onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked when the view-source action is clicked.
*/
/** Invoked when the view-source action is clicked. */
onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
}
/** View model contract for the tile error fallback. */
export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>;
interface TileErrorViewProps {
@ -66,11 +52,11 @@ interface TileErrorViewProps {
*
* The component shows the fallback error message from the view model, optionally
* appends the event type in parentheses, and can render bug-report and view-source
* actions when their labels are provided. The layout in the view-model snapshot
* selects the timeline presentation variant.
* actions when their labels are provided.
*/
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element {
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel, layout = "group" } = useViewModel(vm);
const { layout } = useEventPresentation();
const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel } = useViewModel(vm);
return (
<li

View File

@ -8,7 +8,6 @@
export {
TileErrorView,
type TileErrorViewActions,
type TileErrorViewLayout,
type TileErrorViewModel,
type TileErrorViewSnapshot,
} from "./TileErrorView";

View File

@ -19,6 +19,7 @@ import {
import { useMockedViewModel } from "../../../../core/viewmodel";
import { LinkedTextContext } from "../../../../core/utils/LinkedText";
import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { EventPresentationProvider } from "../../EventPresentation";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
@ -94,7 +95,7 @@ MultiplePreviewsVisible.args = {
{
title: "One",
description: "A regular square image.",
link: "https://matrix.org",
link: "https://matrix.org/one",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@ -108,7 +109,7 @@ MultiplePreviewsVisible.args = {
{
title: "Two",
description: "This one has a taller image which should crop nicely.",
link: "https://matrix.org",
link: "https://matrix.org/two",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@ -121,7 +122,7 @@ MultiplePreviewsVisible.args = {
{
title: "Three",
description: "One more description",
link: "https://matrix.org",
link: "https://matrix.org/three",
siteName: "matrix.org",
showTooltipOnLink: false,
image: {
@ -140,5 +141,11 @@ MultiplePreviewsVisible.args = {
export const WithCompactView = Template.bind({});
WithCompactView.args = {
...MultiplePreviewsVisible.args,
compactLayout: true,
};
WithCompactView.decorators = [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "group", density: "compact" }}>
<Story />
</EventPresentationProvider>
),
];

View File

@ -27,7 +27,7 @@ describe("UrlPreviewGroupView", () => {
const { container } = render(<MultiplePreviewsHidden />);
expect(container).toMatchSnapshot();
});
it("renders with a compact view", () => {
it("renders with compact density", () => {
const { container } = render(<WithCompactView />);
expect(container).toMatchSnapshot();
});

View File

@ -12,18 +12,24 @@ import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import { useI18n } from "../../../../core/i18n/i18nContext";
import { useEventPresentation } from "../../EventPresentation";
import type { UrlPreview } from "./types";
import { LinkPreview } from "./LinkPreview";
import styles from "./UrlPreviewGroupView.module.css";
/** Snapshot data for rendering URL previews attached to an event. */
export interface UrlPreviewGroupViewSnapshot {
/** URL previews to render. */
previews: Array<UrlPreview>;
/** Total number of previews available before limiting. */
totalPreviewCount: number;
/** Whether the preview list is currently limited. */
previewsLimited: boolean;
/** Whether more previews exist than are currently rendered. */
overPreviewLimit: boolean;
compactLayout: boolean;
}
/** Props for the URL preview group view. */
export interface UrlPreviewGroupViewProps {
/**
* The view model for the component.
@ -35,12 +41,17 @@ export interface UrlPreviewGroupViewProps {
className?: string;
}
/** User actions emitted by the URL preview group view. */
export interface UrlPreviewGroupViewActions {
/** Invoked when the preview limit toggle is clicked. */
onTogglePreviewLimit: () => void;
/** Invoked when the hide-preview action is clicked. */
onHideClick: () => Promise<void>;
/** Invoked when a preview image is clicked. */
onImageClick: (preview: UrlPreview) => void;
}
/** View model contract for the URL preview group view. */
export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewActions>;
/**
@ -51,7 +62,8 @@ export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, Ur
*/
export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit, compactLayout } = useViewModel(vm);
const { density } = useEventPresentation();
const { previews, totalPreviewCount, previewsLimited, overPreviewLimit } = useViewModel(vm);
if (previews.length === 0) {
return null;
}
@ -69,7 +81,7 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
return (
<div className={classNames(className, styles.wrapper)}>
<div className={classNames(styles.previewGroup, compactLayout && styles.compactLayout)}>
<div className={classNames(styles.previewGroup, density === "compact" && styles.compactLayout)}>
{previews.map((preview) => (
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
))}

View File

@ -104,7 +104,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/one"
rel="noreferrer noopener"
target="_blank"
>
@ -144,7 +144,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/two"
rel="noreferrer noopener"
target="_blank"
>
@ -184,7 +184,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/three"
rel="noreferrer noopener"
target="_blank"
>
@ -332,7 +332,7 @@ exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] =
</div>
`;
exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
exports[`UrlPreviewGroupView > renders with compact density 1`] = `
<div>
<div
class="UrlPreviewGroupView-module_wrapper"
@ -358,7 +358,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/one"
rel="noreferrer noopener"
target="_blank"
>
@ -398,7 +398,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/two"
rel="noreferrer noopener"
target="_blank"
>
@ -438,7 +438,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
>
<a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title"
href="https://matrix.org"
href="https://matrix.org/three"
rel="noreferrer noopener"
target="_blank"
>