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
23 changed files with 379 additions and 209 deletions
@@ -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>
);
+29 -26
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>
);
}
@@ -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 {
@@ -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} />}
@@ -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>
);
@@ -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>;
}
@@ -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;
}
@@ -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;