mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-08 05:36:13 +02:00
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:
parent
7e26c2d144
commit
1f71a3a3fe
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
50
apps/web/src/utils/EventPresentationContextProvider.tsx
Normal file
50
apps/web/src/utils/EventPresentationContextProvider.tsx
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 { 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 = {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
export {
|
||||
TileErrorView,
|
||||
type TileErrorViewActions,
|
||||
type TileErrorViewLayout,
|
||||
type TileErrorViewModel,
|
||||
type TileErrorViewSnapshot,
|
||||
} from "./TileErrorView";
|
||||
|
||||
@ -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>
|
||||
),
|
||||
];
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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} />
|
||||
))}
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user