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 Measured from "../views/elements/Measured";
import EmptyState from "../views/right_panel/EmptyState"; import EmptyState from "../views/right_panel/EmptyState";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
interface IProps { interface IProps {
roomId: string; roomId: string;
@@ -286,15 +287,17 @@ class FilePanel extends React.Component<IProps, IState> {
> >
<Measured sensor={this.card} onMeasurement={this.onMeasurement} /> <Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} /> <SearchWarning isRoomEncrypted={isRoomEncrypted} kind={WarningKind.Files} />
<TimelinePanel <EventPresentationContextProvider layout={Layout.Group}>
manageReadReceipts={false} <TimelinePanel
manageReadMarkers={false} manageReadReceipts={false}
timelineSet={this.state.timelineSet} manageReadMarkers={false}
showUrlPreview={false} timelineSet={this.state.timelineSet}
onPaginationRequest={this.onPaginationRequest} showUrlPreview={false}
empty={emptyState} onPaginationRequest={this.onPaginationRequest}
layout={Layout.Group} empty={emptyState}
/> layout={Layout.Group}
/>
</EventPresentationContextProvider>
</BaseCard> </BaseCard>
</ScopedRoomContextProvider> </ScopedRoomContextProvider>
); );
+29 -26
View File
@@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts"; import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts";
import { ModuleApi } from "../../modules/Api.ts"; import { ModuleApi } from "../../modules/Api.ts";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
const DEBUG = false; const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@@ -2583,32 +2584,34 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
let messagePanel: JSX.Element | undefined; let messagePanel: JSX.Element | undefined;
if (!isRoomEncryptionLoading) { if (!isRoomEncryptionLoading) {
messagePanel = ( messagePanel = (
<TimelinePanel <EventPresentationContextProvider layout={this.state.layout}>
ref={this.gatherTimelinePanelRef} <TimelinePanel
timelineSet={this.state.room.getUnfilteredTimelineSet()} ref={this.gatherTimelinePanelRef}
showReadReceipts={this.state.showReadReceipts} timelineSet={this.state.room.getUnfilteredTimelineSet()}
manageReadReceipts={!this.state.isPeeking} showReadReceipts={this.state.showReadReceipts}
sendReadReceiptOnLoad={ manageReadReceipts={!this.state.isPeeking}
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity sendReadReceiptOnLoad={
} !this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
manageReadMarkers={!this.state.isPeeking} }
hidden={hideMessagePanel} manageReadMarkers={!this.state.isPeeking}
highlightedEventId={highlightedEventId} hidden={hideMessagePanel}
eventId={this.state.initialEventId} highlightedEventId={highlightedEventId}
eventScrollIntoView={this.state.initialEventScrollIntoView} eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset} eventScrollIntoView={this.state.initialEventScrollIntoView}
onScroll={this.onMessageListScroll} eventPixelOffset={this.state.initialEventPixelOffset}
onEventScrolledIntoView={this.resetJumpToEvent} onScroll={this.onMessageListScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar} onEventScrolledIntoView={this.resetJumpToEvent}
showUrlPreview={this.state.showUrlPreview} onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
className={this.messagePanelClassNames} showUrlPreview={this.state.showUrlPreview}
membersLoaded={this.state.membersLoaded} className={this.messagePanelClassNames}
permalinkCreator={this.permalinkCreator} membersLoaded={this.state.membersLoaded}
showReactions={true} permalinkCreator={this.permalinkCreator}
layout={this.state.layout} showReactions={true}
editState={this.state.editState} layout={this.state.layout}
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity} 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 Heading from "../views/typography/Heading";
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider";
interface IProps { interface IProps {
room: Room; 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 = ( timeline = (
<> <>
<FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} /> <FileDropTarget parent={this.card.current} onFileDrop={this.onFileDrop} room={this.props.room} />
<TimelinePanel <EventPresentationContextProvider layout={layout}>
key={this.state.thread.id} <TimelinePanel
ref={this.timelinePanel} key={this.state.thread.id}
showReadReceipts={this.context.showReadReceipts} ref={this.timelinePanel}
manageReadReceipts={true} showReadReceipts={this.context.showReadReceipts}
manageReadMarkers={true} manageReadReceipts={true}
sendReadReceiptOnLoad={true} manageReadMarkers={true}
timelineSet={this.state.thread.timelineSet} sendReadReceiptOnLoad={true}
showUrlPreview={this.context.showUrlPreview} timelineSet={this.state.thread.timelineSet}
// ThreadView doesn't support IRC layout at this time showUrlPreview={this.context.showUrlPreview}
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group} // ThreadView doesn't support IRC layout at this time
hideThreadedMessages={false} layout={layout}
hidden={false} hideThreadedMessages={false}
showReactions={true} hidden={false}
className="mx_RoomView_messagePanel" showReactions={true}
permalinkCreator={this.props.permalinkCreator} className="mx_RoomView_messagePanel"
membersLoaded={true} permalinkCreator={this.props.permalinkCreator}
editState={this.state.editState} membersLoaded={true}
eventId={this.props.initialEvent?.getId()} editState={this.state.editState}
highlightedEventId={highlightedEventId} eventId={this.props.initialEvent?.getId()}
eventScrollIntoView={this.props.initialEventScrollIntoView} highlightedEventId={highlightedEventId}
onEventScrolledIntoView={this.resetJumpToEvent} eventScrollIntoView={this.props.initialEventScrollIntoView}
/> onEventScrolledIntoView={this.resetJumpToEvent}
/>
</EventPresentationContextProvider>
</> </>
); );
} else { } else {
@@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import Measured from "../elements/Measured"; import Measured from "../elements/Measured";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx";
import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider";
interface IProps { interface IProps {
room: Room; room: Room;
@@ -197,6 +198,7 @@ export default class TimelineCard extends React.Component<IProps, IState> {
const myMembership = this.props.room.getMyMembership(); const myMembership = this.props.room.getMyMembership();
const showComposer = myMembership === KnownMembership.Join; const showComposer = myMembership === KnownMembership.Join;
const layout = this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group;
return ( return (
<ScopedRoomContextProvider <ScopedRoomContextProvider
@@ -215,27 +217,29 @@ export default class TimelineCard extends React.Component<IProps, IState> {
<Measured sensor={this.card} onMeasurement={this.onMeasurement} /> <Measured sensor={this.card} onMeasurement={this.onMeasurement} />
<div className="mx_TimelineCard_timeline"> <div className="mx_TimelineCard_timeline">
{jumpToBottom} {jumpToBottom}
<TimelinePanel <EventPresentationContextProvider layout={layout}>
ref={this.timelinePanel} <TimelinePanel
showReadReceipts={this.state.showReadReceipts} ref={this.timelinePanel}
manageReadReceipts={true} showReadReceipts={this.state.showReadReceipts}
manageReadMarkers={false} // No RM support in the TimelineCard manageReadReceipts={true}
sendReadReceiptOnLoad={true} manageReadMarkers={false} // No RM support in the TimelineCard
timelineSet={this.props.timelineSet} sendReadReceiptOnLoad={true}
showUrlPreview={this.context.showUrlPreview} timelineSet={this.props.timelineSet}
// The right panel timeline (and therefore threads) don't support IRC layout at this time showUrlPreview={this.context.showUrlPreview}
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group} // The right panel timeline (and therefore threads) don't support IRC layout at this time
hideThreadedMessages={false} layout={layout}
hidden={false} hideThreadedMessages={false}
showReactions={true} hidden={false}
className="mx_RoomView_messagePanel" showReactions={true}
permalinkCreator={this.props.permalinkCreator} className="mx_RoomView_messagePanel"
membersLoaded={true} permalinkCreator={this.props.permalinkCreator}
editState={this.state.editState} membersLoaded={true}
eventId={this.state.initialEventId} editState={this.state.editState}
highlightedEventId={highlightedEventId} eventId={this.state.initialEventId}
onScroll={this.onScroll} highlightedEventId={highlightedEventId}
/> onScroll={this.onScroll}
/>
</EventPresentationContextProvider>
</div> </div>
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />} {isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
@@ -57,7 +57,6 @@ import {
ReactionsRowButtonView, ReactionsRowButtonView,
ReactionsRowView, ReactionsRowView,
TileErrorView, TileErrorView,
type TileErrorViewLayout,
useViewModel, useViewModel,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
@@ -1579,24 +1578,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
*/ */
interface EventTileErrorFallbackProps { interface EventTileErrorFallbackProps {
error: Error; error: Error;
layout: Layout;
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
} }
function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element { function EventTileErrorFallback({ error, mxEvent }: Readonly<EventTileErrorFallbackProps>): JSX.Element {
const developerMode = useSettingValue("developerMode"); const developerMode = useSettingValue("developerMode");
const vm = useCreateAutoDisposedViewModel( const vm = useCreateAutoDisposedViewModel(() => new TileErrorViewModel({ error, mxEvent, developerMode }));
() => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }),
);
useEffect(() => { useEffect(() => {
vm.setError(error); vm.setError(error);
}, [error, vm]); }, [error, vm]);
useEffect(() => {
vm.setLayout(layout as TileErrorViewLayout);
}, [layout, vm]);
useEffect(() => { useEffect(() => {
vm.setDeveloperMode(developerMode); vm.setDeveloperMode(developerMode);
}, [developerMode, vm]); }, [developerMode, vm]);
@@ -1606,7 +1598,6 @@ function EventTileErrorFallback({ error, layout, mxEvent }: Readonly<EventTileEr
interface EventTileErrorBoundaryProps { interface EventTileErrorBoundaryProps {
children: ReactNode; children: ReactNode;
layout: Layout;
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
} }
@@ -1626,13 +1617,7 @@ class EventTileErrorBoundary extends React.Component<EventTileErrorBoundaryProps
public render(): ReactNode { public render(): ReactNode {
if (this.state.error) { if (this.state.error) {
return ( return <EventTileErrorFallback error={this.state.error} mxEvent={this.props.mxEvent} />;
<EventTileErrorFallback
error={this.state.error}
layout={this.props.layout}
mxEvent={this.props.mxEvent}
/>
);
} }
return this.props.children; 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 // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = (props: EventTileProps): JSX.Element => { const SafeEventTile = (props: EventTileProps): JSX.Element => {
return ( return (
<EventTileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}> <EventTileErrorBoundary mxEvent={props.mxEvent}>
<UnwrappedEventTile {...props} /> <UnwrappedEventTile {...props} />
</EventTileErrorBoundary> </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 { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { import {
BaseViewModel, BaseViewModel,
type TileErrorViewLayout,
type TileErrorViewSnapshot as TileErrorViewSnapshotInterface, type TileErrorViewSnapshot as TileErrorViewSnapshotInterface,
type TileErrorViewModel as TileErrorViewModelInterface, type TileErrorViewModel as TileErrorViewModelInterface,
} from "@element-hq/web-shared-components"; } 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"; const TILE_ERROR_BUG_REPORT_LABEL = "react-tile-soft-crash";
export interface TileErrorViewModelProps { export interface TileErrorViewModelProps {
/**
* Layout variant used by the host timeline.
*/
layout: TileErrorViewLayout;
/** /**
* Event whose tile failed to render. * 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. * 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, * The snapshot includes the fallback message, event type, and optional bug-report and
* and optional bug-report and view-source action labels. The view model also exposes * view-source action labels. The view model also exposes click handlers for those
* click handlers for those actions, opening the bug-report or view-source dialog when * actions, opening the bug-report or view-source dialog when available.
* available.
*/ */
export class TileErrorViewModel export class TileErrorViewModel
extends BaseViewModel<TileErrorViewSnapshotInterface, TileErrorViewModelProps> extends BaseViewModel<TileErrorViewSnapshotInterface, TileErrorViewModelProps>
implements TileErrorViewModelInterface implements TileErrorViewModelInterface
{ {
private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({ private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({
layout: props.layout,
message: _t("timeline|error_rendering_message"), message: _t("timeline|error_rendering_message"),
eventType: props.mxEvent.getType(), eventType: props.mxEvent.getType(),
bugReportCtaLabel: getBugReportCtaLabel(), bugReportCtaLabel: getBugReportCtaLabel(),
@@ -85,11 +78,6 @@ export class TileErrorViewModel
super(props, TileErrorViewModel.computeSnapshot(props)); super(props, TileErrorViewModel.computeSnapshot(props));
} }
public setLayout(layout: TileErrorViewLayout): void {
this.props.layout = layout;
this.snapshot.merge({ layout });
}
public setError(error: Error): void { public setError(error: Error): void {
this.props.error = error; this.props.error = error;
} }
@@ -20,7 +20,6 @@ import { isPermalinkHost } from "../../utils/permalinks/Permalinks";
import { mediaFromMxc } from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import { thumbHeight } from "../../ImageUtils"; import { thumbHeight } from "../../ImageUtils";
import SettingsStore from "../../settings/SettingsStore";
import { PosthogAnalytics } from "../../PosthogAnalytics"; import { PosthogAnalytics } from "../../PosthogAnalytics";
const logger = rootLogger.getChild("UrlPreviewGroupViewModel"); const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
@@ -239,7 +238,6 @@ export class UrlPreviewGroupViewModel
private readonly client: MatrixClient; private readonly client: MatrixClient;
private readonly storageKey: string; private readonly storageKey: string;
private readonly eventSendTime: number; private readonly eventSendTime: number;
private readonly useCompactLayoutSettingWatcher: string;
/** /**
* Should the URL preview render according to the application. * Should the URL preview render according to the application.
@@ -282,7 +280,6 @@ export class UrlPreviewGroupViewModel
totalPreviewCount: 0, totalPreviewCount: 0,
previewsLimited: true, previewsLimited: true,
overPreviewLimit: false, overPreviewLimit: false,
compactLayout: SettingsStore.getValue("useCompactLayout"),
}); });
this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1"; this.urlPreviewEnabledByUser = globalThis.localStorage.getItem(storageKey) !== "1";
this.urlPreviewVisible = props.visible; this.urlPreviewVisible = props.visible;
@@ -291,15 +288,6 @@ export class UrlPreviewGroupViewModel
this.client = props.client; this.client = props.client;
this.eventSendTime = props.mxEvent.getTs(); this.eventSendTime = props.mxEvent.getTs();
this.onImageClick = props.onImageClicked; 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; return result;
} }
public dispose(): void {
super.dispose();
SettingsStore.unwatchSetting(this.useCompactLayoutSettingWatcher);
}
private get visibility(): PreviewVisibility { private get visibility(): PreviewVisibility {
if (!this.urlPreviewVisible) { if (!this.urlPreviewVisible) {
return PreviewVisibility.Hidden; return PreviewVisibility.Hidden;
@@ -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"));
});
});
@@ -33,7 +33,6 @@ describe("TileErrorViewModel", () => {
const mxEvent = overrides.mxEvent ?? createEvent(); const mxEvent = overrides.mxEvent ?? createEvent();
return new TileErrorViewModel({ return new TileErrorViewModel({
layout: "group",
developerMode: true, developerMode: true,
error, error,
mxEvent, mxEvent,
@@ -56,7 +55,6 @@ describe("TileErrorViewModel", () => {
const vm = createVm(); const vm = createVm();
expect(vm.getSnapshot()).toEqual({ expect(vm.getSnapshot()).toEqual({
layout: "group",
message: "Can't load this message", message: "Can't load this message",
eventType: "m.room.message", eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs", bugReportCtaLabel: "Submit debug logs",
@@ -78,17 +76,6 @@ describe("TileErrorViewModel", () => {
expect(vm.getSnapshot().viewSourceCtaLabel).toBeUndefined(); 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", () => { it("guards setters against unchanged values", () => {
const error = new Error("Boom"); const error = new Error("Boom");
const mxEvent = createEvent(); const mxEvent = createEvent();
@@ -98,7 +85,6 @@ describe("TileErrorViewModel", () => {
vm.setDeveloperMode(true); vm.setDeveloperMode(true);
vm.setError(error); vm.setError(error);
vm.setLayout("group");
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
}); });
@@ -75,7 +75,6 @@ exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses
exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = ` exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [ "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`] = ` exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [], "previews": [],
"previewsLimited": true, "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`] = ` exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 2`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [ "previews": [
{ {
@@ -127,7 +124,6 @@ exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the us
exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = ` exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [], "previews": [],
"previewsLimited": true, "previewsLimited": true,
@@ -137,7 +133,6 @@ exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = ` exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [], "previews": [],
"previewsLimited": true, "previewsLimited": true,
@@ -147,7 +142,6 @@ exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false 1`] = ` exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [ "previews": [
{ {
@@ -168,7 +162,6 @@ exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false
exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = ` exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [ "previews": [
{ {
@@ -197,7 +190,6 @@ exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = ` exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [ "previews": [
{ {
@@ -218,7 +210,6 @@ exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
exports[`UrlPreviewGroupViewModel should return no previews by default 1`] = ` exports[`UrlPreviewGroupViewModel should return no previews by default 1`] = `
{ {
"compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
"previews": [], "previews": [],
"previewsLimited": true, "previewsLimited": true,
@@ -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/400.css";
import "@fontsource/inter/500.css"; import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css"; import "@fontsource/inter/600.css";
@@ -7,10 +14,11 @@ import "@fontsource/inter/700.css";
import "./compound.css"; import "./compound.css";
import "./preview.css"; import "./preview.css";
import React, { useLayoutEffect } from "react"; import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/core/i18n/i18n";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf"; import type { StoryContext } from "storybook/internal/csf";
import { I18nApi, I18nContext } from "../src";
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
import { setLanguage } from "../src/core/i18n/i18n";
export const globalTypes = { export const globalTypes = {
theme: { theme: {
@@ -32,9 +40,36 @@ export const globalTypes = {
name: "Language", name: "Language",
description: "Global language for components", 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: { initialGlobals: {
theme: "system", theme: "system",
language: "en", language: "en",
eventLayout: "group",
eventDensity: "default",
}, },
} satisfies ArgTypes; } 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"], tags: ["autodocs", "snapshot"],
decorators: [withThemeProvider, withTooltipProvider, withI18nProvider], initialGlobals: {
theme: "system",
language: "en",
eventLayout: "group",
eventDensity: "default",
},
decorators: [withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
parameters: { parameters: {
options: { options: {
storySort: { storySort: {
@@ -101,6 +155,6 @@ const preview: Preview = {
}, },
}, },
loaders: [languageLoader], loaders: [languageLoader],
}; } satisfies Preview;
export default preview; export default preview;
+1
View File
@@ -14,6 +14,7 @@ export * from "./core/roving";
export * from "./room/composer/Banner"; export * from "./room/composer/Banner";
export * from "./crypto/SasEmoji"; export * from "./crypto/SasEmoji";
export * from "./room/timeline/ReadMarker"; 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/EventContentBodyView";
export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView";
export * from "./room/timeline/event-tile/body/MFileBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView";
@@ -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);
}
@@ -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";
@@ -11,6 +11,7 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import { useMockedViewModel } from "../../../../../core/viewmodel"; import { useMockedViewModel } from "../../../../../core/viewmodel";
import { EventPresentationProvider } from "../../../EventPresentation";
import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView"; import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView";
type WrapperProps = TileErrorViewSnapshot & type WrapperProps = TileErrorViewSnapshot &
@@ -49,7 +50,6 @@ const meta = {
eventType: "m.room.message", eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs", bugReportCtaLabel: "Submit debug logs",
viewSourceCtaLabel: "View source", viewSourceCtaLabel: "View source",
layout: "group",
}, },
} satisfies Meta<typeof TileErrorViewWrapper>; } satisfies Meta<typeof TileErrorViewWrapper>;
@@ -59,9 +59,13 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {}; export const Default: Story = {};
export const BubbleLayout: Story = { export const BubbleLayout: Story = {
args: { decorators: [
layout: "bubble", (Story): JSX.Element => (
}, <EventPresentationProvider value={{ layout: "bubble", density: "default" }}>
<Story />
</EventPresentationProvider>
),
],
}; };
export const WithoutActions: Story = { export const WithoutActions: Story = {
@@ -58,7 +58,6 @@ describe("TileErrorView", () => {
const vm = new TestTileErrorViewModel( const vm = new TestTileErrorViewModel(
{ {
layout: "group",
message: "Can't load this message", message: "Can't load this message",
eventType: "m.room.message", eventType: "m.room.message",
bugReportCtaLabel: "Submit debug logs", bugReportCtaLabel: "Submit debug logs",
@@ -81,7 +80,6 @@ describe("TileErrorView", () => {
it("applies a custom className to the root element", () => { it("applies a custom className to the root element", () => {
const vm = new MockViewModel<TileErrorViewSnapshot>({ const vm = new MockViewModel<TileErrorViewSnapshot>({
layout: "group",
message: "Can't load this message", message: "Can't load this message",
}) as TileErrorViewModel; }) as TileErrorViewModel;
@@ -10,44 +10,30 @@ import classNames from "classnames";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import { useEventPresentation } from "../../../EventPresentation";
import styles from "./TileErrorView.module.css"; import styles from "./TileErrorView.module.css";
export type TileErrorViewLayout = "bubble" | "group" | "irc"; /** Snapshot data for rendering an event tile error fallback. */
export interface TileErrorViewSnapshot { export interface TileErrorViewSnapshot {
/** /** Primary fallback text shown when a tile fails to render. */
* Layout variant used by the host timeline.
*/
layout?: TileErrorViewLayout;
/**
* Primary fallback text shown when a tile fails to render.
*/
message: string; message: string;
/** /** Optional event type appended to the fallback text. */
* Optional event type appended to the fallback text.
*/
eventType?: string; eventType?: string;
/** /** Optional label for the bug-report action button. */
* Optional label for the bug-report action button.
*/
bugReportCtaLabel?: string; bugReportCtaLabel?: string;
/** /** Optional label for the view-source action. */
* Optional label for the view-source action.
*/
viewSourceCtaLabel?: string; viewSourceCtaLabel?: string;
} }
/** User actions emitted by the tile error fallback. */
export interface TileErrorViewActions { export interface TileErrorViewActions {
/** /** Invoked when the bug-report button is clicked. */
* Invoked when the bug-report button is clicked.
*/
onBugReportClick?: MouseEventHandler<HTMLButtonElement>; onBugReportClick?: MouseEventHandler<HTMLButtonElement>;
/** /** Invoked when the view-source action is clicked. */
* Invoked when the view-source action is clicked.
*/
onViewSourceClick?: MouseEventHandler<HTMLButtonElement>; onViewSourceClick?: MouseEventHandler<HTMLButtonElement>;
} }
/** View model contract for the tile error fallback. */
export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>; export type TileErrorViewModel = ViewModel<TileErrorViewSnapshot, TileErrorViewActions>;
interface TileErrorViewProps { interface TileErrorViewProps {
@@ -66,11 +52,11 @@ interface TileErrorViewProps {
* *
* The component shows the fallback error message from the view model, optionally * 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 * 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 * actions when their labels are provided.
* selects the timeline presentation variant.
*/ */
export function TileErrorView({ vm, className }: Readonly<TileErrorViewProps>): JSX.Element { 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 ( return (
<li <li
@@ -8,7 +8,6 @@
export { export {
TileErrorView, TileErrorView,
type TileErrorViewActions, type TileErrorViewActions,
type TileErrorViewLayout,
type TileErrorViewModel, type TileErrorViewModel,
type TileErrorViewSnapshot, type TileErrorViewSnapshot,
} from "./TileErrorView"; } from "./TileErrorView";
@@ -19,6 +19,7 @@ import {
import { useMockedViewModel } from "../../../../core/viewmodel"; import { useMockedViewModel } from "../../../../core/viewmodel";
import { LinkedTextContext } from "../../../../core/utils/LinkedText"; import { LinkedTextContext } from "../../../../core/utils/LinkedText";
import { withViewDocs } from "../../../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../../../.storybook/withViewDocs";
import { EventPresentationProvider } from "../../EventPresentation";
type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions; type UrlPreviewGroupViewProps = UrlPreviewGroupViewSnapshot & UrlPreviewGroupViewActions;
@@ -94,7 +95,7 @@ MultiplePreviewsVisible.args = {
{ {
title: "One", title: "One",
description: "A regular square image.", description: "A regular square image.",
link: "https://matrix.org", link: "https://matrix.org/one",
siteName: "matrix.org", siteName: "matrix.org",
showTooltipOnLink: false, showTooltipOnLink: false,
image: { image: {
@@ -108,7 +109,7 @@ MultiplePreviewsVisible.args = {
{ {
title: "Two", title: "Two",
description: "This one has a taller image which should crop nicely.", description: "This one has a taller image which should crop nicely.",
link: "https://matrix.org", link: "https://matrix.org/two",
siteName: "matrix.org", siteName: "matrix.org",
showTooltipOnLink: false, showTooltipOnLink: false,
image: { image: {
@@ -121,7 +122,7 @@ MultiplePreviewsVisible.args = {
{ {
title: "Three", title: "Three",
description: "One more description", description: "One more description",
link: "https://matrix.org", link: "https://matrix.org/three",
siteName: "matrix.org", siteName: "matrix.org",
showTooltipOnLink: false, showTooltipOnLink: false,
image: { image: {
@@ -140,5 +141,11 @@ MultiplePreviewsVisible.args = {
export const WithCompactView = Template.bind({}); export const WithCompactView = Template.bind({});
WithCompactView.args = { WithCompactView.args = {
...MultiplePreviewsVisible.args, ...MultiplePreviewsVisible.args,
compactLayout: true,
}; };
WithCompactView.decorators = [
(Story): JSX.Element => (
<EventPresentationProvider value={{ layout: "group", density: "compact" }}>
<Story />
</EventPresentationProvider>
),
];
@@ -27,7 +27,7 @@ describe("UrlPreviewGroupView", () => {
const { container } = render(<MultiplePreviewsHidden />); const { container } = render(<MultiplePreviewsHidden />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders with a compact view", () => { it("renders with compact density", () => {
const { container } = render(<WithCompactView />); const { container } = render(<WithCompactView />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
@@ -12,18 +12,24 @@ import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
import { useI18n } from "../../../../core/i18n/i18nContext"; import { useI18n } from "../../../../core/i18n/i18nContext";
import { useEventPresentation } from "../../EventPresentation";
import type { UrlPreview } from "./types"; import type { UrlPreview } from "./types";
import { LinkPreview } from "./LinkPreview"; import { LinkPreview } from "./LinkPreview";
import styles from "./UrlPreviewGroupView.module.css"; import styles from "./UrlPreviewGroupView.module.css";
/** Snapshot data for rendering URL previews attached to an event. */
export interface UrlPreviewGroupViewSnapshot { export interface UrlPreviewGroupViewSnapshot {
/** URL previews to render. */
previews: Array<UrlPreview>; previews: Array<UrlPreview>;
/** Total number of previews available before limiting. */
totalPreviewCount: number; totalPreviewCount: number;
/** Whether the preview list is currently limited. */
previewsLimited: boolean; previewsLimited: boolean;
/** Whether more previews exist than are currently rendered. */
overPreviewLimit: boolean; overPreviewLimit: boolean;
compactLayout: boolean;
} }
/** Props for the URL preview group view. */
export interface UrlPreviewGroupViewProps { export interface UrlPreviewGroupViewProps {
/** /**
* The view model for the component. * The view model for the component.
@@ -35,12 +41,17 @@ export interface UrlPreviewGroupViewProps {
className?: string; className?: string;
} }
/** User actions emitted by the URL preview group view. */
export interface UrlPreviewGroupViewActions { export interface UrlPreviewGroupViewActions {
/** Invoked when the preview limit toggle is clicked. */
onTogglePreviewLimit: () => void; onTogglePreviewLimit: () => void;
/** Invoked when the hide-preview action is clicked. */
onHideClick: () => Promise<void>; onHideClick: () => Promise<void>;
/** Invoked when a preview image is clicked. */
onImageClick: (preview: UrlPreview) => void; onImageClick: (preview: UrlPreview) => void;
} }
/** View model contract for the URL preview group view. */
export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewActions>; 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 { export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps): JSX.Element | null {
const { translate: _t } = useI18n(); 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) { if (previews.length === 0) {
return null; return null;
} }
@@ -69,7 +81,7 @@ export function UrlPreviewGroupView({ vm, className }: UrlPreviewGroupViewProps)
return ( return (
<div className={classNames(className, styles.wrapper)}> <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) => ( {previews.map((preview) => (
<LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} /> <LinkPreview key={preview.link} onImageClick={() => vm.onImageClick(preview)} {...preview} />
))} ))}
@@ -104,7 +104,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >
@@ -144,7 +144,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >
@@ -184,7 +184,7 @@ exports[`UrlPreviewGroupView > renders multiple previews 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >
@@ -332,7 +332,7 @@ exports[`UrlPreviewGroupView > renders multiple previews which are hidden 1`] =
</div> </div>
`; `;
exports[`UrlPreviewGroupView > renders with a compact view 1`] = ` exports[`UrlPreviewGroupView > renders with compact density 1`] = `
<div> <div>
<div <div
class="UrlPreviewGroupView-module_wrapper" class="UrlPreviewGroupView-module_wrapper"
@@ -358,7 +358,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >
@@ -398,7 +398,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >
@@ -438,7 +438,7 @@ exports[`UrlPreviewGroupView > renders with a compact view 1`] = `
> >
<a <a
class="_typography_6v6n8_153 _font-body-md-semibold_6v6n8_55 LinkPreview-module_title" 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" rel="noreferrer noopener"
target="_blank" target="_blank"
> >