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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
-9
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+39
@@ -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";
|
||||||
+8
-4
@@ -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 = {
|
||||||
|
|||||||
-2
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+13
-27
@@ -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
|
||||||
|
|||||||
-1
@@ -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";
|
||||||
|
|||||||
+11
-4
@@ -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>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|||||||
+1
-1
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-3
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+7
-7
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user