From d197fb4e301980abe79dde05313440fcc4d77f6a Mon Sep 17 00:00:00 2001 From: Zack Date: Wed, 8 Apr 2026 11:05:31 +0200 Subject: [PATCH] Refactor and Move TileErrorBoundary to Shared Components (#32793) * creation of stories and view in shared-components * migrate EventTile error fallback to shared TileErrorView MVVM * Fix lint errors and unused import * Update tests because of the refactoring * Update snapshots + stories * removal of mxEvent since it never changes in timeline * Update packages/shared-components/src/message-body/TileErrorView/TileErrorView.stories.tsx Co-authored-by: Florian Duros * Update apps/web/src/viewmodels/message-body/TileErrorViewModel.ts Co-authored-by: Florian Duros * Update apps/web/src/viewmodels/message-body/TileErrorViewModel.ts Co-authored-by: Florian Duros * docs: add TileErrorView tsdoc * docs: add TileErrorViewModel tsdoc * docs: add view source label tsdoc * refactor: move tile error layout into vm * docs: add TileErrorView story view docs * docs: move tile error story list wrapper * refactor: remove unused tile error event setter * Update packages/shared-components/src/message-body/TileErrorView/TileErrorView.stories.tsx Co-authored-by: Florian Duros * docs: add tsdoc for event tile error fallback props * refactor: rely on snapshot merge no-op checks * remove unessecery if statment * test: restore EventTile mocks in afterEach * test(shared-components): move TileErrorView baselines --------- Co-authored-by: Florian Duros --- .../views/messages/TileErrorBoundary.tsx | 92 ----------- .../src/components/views/rooms/EventTile.tsx | 74 ++++++++- .../message-body/TileErrorViewModel.ts | 128 ++++++++++++++++ .../messages/MKeyVerificationRequest-test.tsx | 37 ++++- .../components/views/rooms/EventTile-test.tsx | 16 +- .../message-body/TileErrorViewModel-test.tsx | 143 ++++++++++++++++++ .../bubble-layout-auto.png | Bin 0 -> 11369 bytes .../default-auto.png | Bin 0 -> 11334 bytes .../without-actions-auto.png | Bin 0 -> 7191 bytes packages/shared-components/src/index.ts | 1 + .../TileErrorView/TileErrorView.module.css | 38 +++++ .../TileErrorView/TileErrorView.stories.tsx | 72 +++++++++ .../TileErrorView/TileErrorView.test.tsx | 92 +++++++++++ .../TileErrorView/TileErrorView.tsx | 98 ++++++++++++ .../__snapshots__/TileErrorView.test.tsx.snap | 77 ++++++++++ .../EventTileView/TileErrorView/index.tsx | 14 ++ 16 files changed, 780 insertions(+), 102 deletions(-) delete mode 100644 apps/web/src/components/views/messages/TileErrorBoundary.tsx create mode 100644 apps/web/src/viewmodels/message-body/TileErrorViewModel.ts create mode 100644 apps/web/test/viewmodels/message-body/TileErrorViewModel-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/bubble-layout-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/without-actions-auto.png create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.module.css create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/index.tsx diff --git a/apps/web/src/components/views/messages/TileErrorBoundary.tsx b/apps/web/src/components/views/messages/TileErrorBoundary.tsx deleted file mode 100644 index 2d11371166..0000000000 --- a/apps/web/src/components/views/messages/TileErrorBoundary.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020-2022 The Matrix.org Foundation C.I.C. - -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 ReactNode } from "react"; -import classNames from "classnames"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; -import AccessibleButton from "../elements/AccessibleButton"; -import SettingsStore from "../../../settings/SettingsStore"; -import ViewSource from "../../structures/ViewSource"; -import { type Layout } from "../../../settings/enums/Layout"; -import { BugReportDialogButton } from "../elements/BugReportDialogButton"; - -interface IProps { - mxEvent: MatrixEvent; - layout: Layout; - children: ReactNode; -} - -interface IState { - error?: Error; -} - -export default class TileErrorBoundary extends React.Component { - public constructor(props: IProps) { - super(props); - - this.state = {}; - } - - public static getDerivedStateFromError(error: Error): Partial { - // Side effects are not permitted here, so we only update the state so - // that the next render shows an error message. - return { error }; - } - - private onViewSource = (): void => { - Modal.createDialog( - ViewSource, - { - mxEvent: this.props.mxEvent, - }, - "mx_Dialog_viewsource", - ); - }; - - public render(): ReactNode { - if (this.state.error) { - const { mxEvent } = this.props; - const classes = { - mx_EventTile: true, - mx_EventTile_info: true, - mx_EventTile_content: true, - mx_EventTile_tileError: true, - }; - - let viewSourceButton; - if (mxEvent && SettingsStore.getValue("developerMode")) { - viewSourceButton = ( - <> -   - - {_t("action|view_source")} - - - ); - } - - return ( -
  • -
    - - {_t("timeline|error_rendering_message")} - {mxEvent && ` (${mxEvent.getType()})`} - - {viewSourceButton} - -
    -
  • - ); - } - - return this.props.children; - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 922f0171cf..01d98415f8 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -56,6 +56,8 @@ import { PinnedMessageBadge, ReactionsRowButtonView, ReactionsRowView, + TileErrorView, + type TileErrorViewLayout, useViewModel, } from "@element-hq/web-shared-components"; @@ -89,7 +91,6 @@ import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { shouldDisplayReply } from "../../../utils/Reply"; import PosthogTrackers from "../../../PosthogTrackers"; -import TileErrorBoundary from "../messages/TileErrorBoundary"; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from "./ReadReceiptGroup"; @@ -114,9 +115,11 @@ import { MAX_ITEMS_WHEN_LIMITED, ReactionsRowViewModel, } from "../../../viewmodels/room/timeline/event-tile/reactions/ReactionsRowViewModel"; +import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; export type GetRelationsForEvent = ( @@ -1571,12 +1574,77 @@ export class UnwrappedEventTile extends React.Component } } +/** + * Props for the event-tile fallback rendered after the tile error boundary catches a render failure. + */ +interface EventTileErrorFallbackProps { + error: Error; + layout: Layout; + mxEvent: MatrixEvent; +} + +function EventTileErrorFallback({ error, layout, mxEvent }: Readonly): JSX.Element { + const developerMode = useSettingValue("developerMode"); + const vm = useCreateAutoDisposedViewModel( + () => new TileErrorViewModel({ error, layout: layout as TileErrorViewLayout, mxEvent, developerMode }), + ); + + useEffect(() => { + vm.setError(error); + }, [error, vm]); + + useEffect(() => { + vm.setLayout(layout as TileErrorViewLayout); + }, [layout, vm]); + + useEffect(() => { + vm.setDeveloperMode(developerMode); + }, [developerMode, vm]); + + return ; +} + +interface EventTileErrorBoundaryProps { + children: ReactNode; + layout: Layout; + mxEvent: MatrixEvent; +} + +interface EventTileErrorBoundaryState { + error?: Error; +} + +class EventTileErrorBoundary extends React.Component { + public constructor(props: EventTileErrorBoundaryProps) { + super(props); + this.state = {}; + } + + public static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + public render(): ReactNode { + if (this.state.error) { + return ( + + ); + } + + return this.props.children; + } +} + // 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 ( - + - + ); }; export default SafeEventTile; diff --git a/apps/web/src/viewmodels/message-body/TileErrorViewModel.ts b/apps/web/src/viewmodels/message-body/TileErrorViewModel.ts new file mode 100644 index 0000000000..3f8fbe10a0 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/TileErrorViewModel.ts @@ -0,0 +1,128 @@ +/* + * 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 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"; + +import { _t } from "../../languageHandler"; +import Modal from "../../Modal"; +import SdkConfig from "../../SdkConfig"; +import { BugReportEndpointURLLocal } from "../../IConfigOptions"; +import ViewSource from "../../components/structures/ViewSource"; +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. + */ + mxEvent: MatrixEvent; + /** + * Render error captured by the boundary. + */ + error: Error; + /** + * Whether developer mode is enabled, which controls the view-source action. + */ + developerMode: boolean; +} + +function getBugReportCtaLabel(): string | undefined { + const bugReportUrl = SdkConfig.get().bug_report_endpoint_url; + + if (!bugReportUrl) { + return undefined; + } + + return bugReportUrl === BugReportEndpointURLLocal + ? _t("bug_reporting|download_logs") + : _t("bug_reporting|submit_debug_logs"); +} + +/** + * Returns the localized view-source action label when developer mode is enabled. + */ +function getViewSourceCtaLabel(developerMode: boolean): string | undefined { + return developerMode ? _t("action|view_source") : 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. + */ +export class TileErrorViewModel + extends BaseViewModel + implements TileErrorViewModelInterface +{ + private static readonly computeSnapshot = (props: TileErrorViewModelProps): TileErrorViewSnapshotInterface => ({ + layout: props.layout, + message: _t("timeline|error_rendering_message"), + eventType: props.mxEvent.getType(), + bugReportCtaLabel: getBugReportCtaLabel(), + viewSourceCtaLabel: getViewSourceCtaLabel(props.developerMode), + }); + + public constructor(props: TileErrorViewModelProps) { + 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; + } + + public setDeveloperMode(developerMode: boolean): void { + this.props.developerMode = developerMode; + + const nextViewSourceCtaLabel = getViewSourceCtaLabel(developerMode); + this.snapshot.merge({ viewSourceCtaLabel: nextViewSourceCtaLabel }); + } + + public onBugReportClick: MouseEventHandler = () => { + if (!this.snapshot.current.bugReportCtaLabel) { + return; + } + + Modal.createDialog(BugReportDialog, { + label: TILE_ERROR_BUG_REPORT_LABEL, + error: this.props.error, + }); + }; + + public onViewSourceClick: MouseEventHandler = () => { + if (!this.snapshot.current.viewSourceCtaLabel) { + return; + } + + Modal.createDialog( + ViewSource, + { + mxEvent: this.props.mxEvent, + }, + "mx_Dialog_viewsource", + ); + }; +} diff --git a/apps/web/test/unit-tests/components/views/messages/MKeyVerificationRequest-test.tsx b/apps/web/test/unit-tests/components/views/messages/MKeyVerificationRequest-test.tsx index a559fd2b6d..0265bd44d1 100644 --- a/apps/web/test/unit-tests/components/views/messages/MKeyVerificationRequest-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MKeyVerificationRequest-test.tsx @@ -11,8 +11,6 @@ import { type RenderResult, render } from "jest-matrix-react"; import { type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import MKeyVerificationRequest from "../../../../../src/components/views/messages/MKeyVerificationRequest"; -import TileErrorBoundary from "../../../../../src/components/views/messages/TileErrorBoundary"; -import { Layout } from "../../../../../src/settings/enums/Layout"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { filterConsole } from "../../../../test-utils"; @@ -60,22 +58,49 @@ describe("MKeyVerificationRequest", () => { }); }); +interface TestTileErrorBoundaryProps { + children: React.ReactNode; +} + +interface TestTileErrorBoundaryState { + error?: Error; +} + +class TestTileErrorBoundary extends React.Component { + public constructor(props: TestTileErrorBoundaryProps) { + super(props); + this.state = {}; + } + + public static getDerivedStateFromError(error: Error): Partial { + return { error }; + } + + public render(): React.ReactNode { + if (this.state.error) { + return "Can't load this message"; + } + + return this.props.children; + } +} + function renderEventNoClient(event: MatrixEvent): RenderResult { return render( - + - , + , ); } function renderEvent(client: MatrixClient, event: MatrixEvent): RenderResult { return render( - + , - , + , ); } diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 3b4f6b3291..42e43771de 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -31,6 +31,7 @@ import { mkEncryptedMatrixEvent } from "matrix-js-sdk/src/testing"; import { getByTestId } from "@testing-library/dom"; import EventTile, { type EventTileProps } from "../../../../../src/components/views/rooms/EventTile"; +import * as EventTileFactory from "../../../../../src/events/EventTileFactory"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { type RoomContextType, TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; @@ -106,7 +107,7 @@ describe("EventTile", () => { }); afterEach(() => { - jest.spyOn(PinningUtils, "isPinned").mockReturnValue(false); + jest.restoreAllMocks(); }); describe("EventTile thread summary", () => { @@ -200,6 +201,19 @@ describe("EventTile", () => { expect(screen.getByText("Pinned message")).toBeInTheDocument(); }, ); + + it("renders the tile error fallback when tile rendering throws", async () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(EventTileFactory, "renderTile").mockImplementation(() => { + throw new Error("Boom"); + }); + + getComponent(); + + await waitFor(() => { + expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument(); + }); + }); }); describe("EventTile in the right panel", () => { diff --git a/apps/web/test/viewmodels/message-body/TileErrorViewModel-test.tsx b/apps/web/test/viewmodels/message-body/TileErrorViewModel-test.tsx new file mode 100644 index 0000000000..d5d8c94e8e --- /dev/null +++ b/apps/web/test/viewmodels/message-body/TileErrorViewModel-test.tsx @@ -0,0 +1,143 @@ +/* + * 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 { mocked } from "jest-mock"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../src/Modal"; +import SdkConfig from "../../../src/SdkConfig"; +import { BugReportEndpointURLLocal } from "../../../src/IConfigOptions"; +import ViewSource from "../../../src/components/structures/ViewSource"; +import BugReportDialog from "../../../src/components/views/dialogs/BugReportDialog"; +import { TileErrorViewModel } from "../../../src/viewmodels/message-body/TileErrorViewModel"; + +describe("TileErrorViewModel", () => { + const createEvent = (type = "m.room.message"): MatrixEvent => + new MatrixEvent({ + content: {}, + event_id: `$${type}`, + origin_server_ts: Date.now(), + room_id: "!room:example.org", + sender: "@alice:example.org", + type, + }); + + const createVm = ( + overrides: Partial[0]> = {}, + ): TileErrorViewModel => { + const error = overrides.error ?? new Error("Boom"); + const mxEvent = overrides.mxEvent ?? createEvent(); + + return new TileErrorViewModel({ + layout: "group", + developerMode: true, + error, + mxEvent, + ...overrides, + }); + }; + + beforeEach(() => { + SdkConfig.reset(); + jest.spyOn(Modal, "createDialog").mockImplementation(() => ({ close: jest.fn() }) as any); + }); + + afterEach(() => { + SdkConfig.reset(); + jest.restoreAllMocks(); + }); + + it("computes the initial snapshot from app state", () => { + SdkConfig.add({ bug_report_endpoint_url: "https://example.org" }); + const vm = createVm(); + + expect(vm.getSnapshot()).toEqual({ + layout: "group", + message: "Can't load this message", + eventType: "m.room.message", + bugReportCtaLabel: "Submit debug logs", + viewSourceCtaLabel: "View Source", + }); + }); + + it("uses the download logs label for local bug reports", () => { + SdkConfig.add({ bug_report_endpoint_url: BugReportEndpointURLLocal }); + const vm = createVm(); + + expect(vm.getSnapshot().bugReportCtaLabel).toBe("Download logs"); + }); + + it("hides optional actions when unavailable", () => { + const vm = createVm({ developerMode: false }); + + expect(vm.getSnapshot().bugReportCtaLabel).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", () => { + const error = new Error("Boom"); + const mxEvent = createEvent(); + const vm = createVm({ developerMode: true, error, mxEvent }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setDeveloperMode(true); + vm.setError(error); + vm.setLayout("group"); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("opens the bug report dialog with the current error", () => { + SdkConfig.add({ bug_report_endpoint_url: "https://example.org" }); + const originalError = new Error("Boom"); + const updatedError = new Error("Updated boom"); + const vm = createVm({ error: originalError }); + + vm.setError(updatedError); + vm.onBugReportClick({} as any); + + expect(Modal.createDialog).toHaveBeenCalledWith(BugReportDialog, { + label: "react-tile-soft-crash", + error: updatedError, + }); + }); + + it("opens the view source dialog with the current event", () => { + const mxEvent = createEvent("m.room.redaction"); + const vm = createVm({ mxEvent }); + + vm.onViewSourceClick({} as any); + + expect(Modal.createDialog).toHaveBeenCalledWith( + ViewSource, + { + mxEvent, + }, + "mx_Dialog_viewsource", + ); + }); + + it("does not open view source when developer mode is disabled", () => { + const vm = createVm({ developerMode: false }); + + vm.onViewSourceClick({} as any); + + expect(mocked(Modal.createDialog)).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/bubble-layout-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/bubble-layout-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..494ff51eb44425ab32bb89ab3a5e11b8e342bf5c GIT binary patch literal 11369 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 9b824a5a65..c1f53abbb7 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -17,6 +17,7 @@ export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; +export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; export * from "./core/pill-input/Pill"; export * from "./core/pill-input/PillInput"; export * from "./room/RoomStatusBar"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.module.css new file mode 100644 index 0000000000..3a18833f7c --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.module.css @@ -0,0 +1,38 @@ +.tileErrorView { + color: var(--cpd-color-text-critical-primary); + list-style: none; + text-align: center; +} + +.line { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--cpd-space-2x); +} + +.bubble .line { + flex-direction: column; +} + +.message { + padding: var(--cpd-space-1x) var(--cpd-space-4x); +} + +.viewSourceButton { + appearance: none; + padding: 0; + border: 0; + background: none; + color: var(--cpd-color-text-action-primary); + cursor: pointer; + font: inherit; + text-decoration: underline; +} + +.viewSourceButton:focus-visible { + outline: 2px solid var(--cpd-color-border-focused); + outline-offset: 2px; + border-radius: var(--cpd-space-1x); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx new file mode 100644 index 0000000000..30b1961d2c --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx @@ -0,0 +1,72 @@ +/* + * 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 ComponentProps, type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { TileErrorView, type TileErrorViewActions, type TileErrorViewSnapshot } from "./TileErrorView"; + +type WrapperProps = TileErrorViewSnapshot & + Partial & + Omit, "vm">; + +const TileErrorViewWrapperImpl = ({ + className, + onBugReportClick = fn(), + onViewSourceClick = fn(), + ...snapshotProps +}: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, { + onBugReportClick, + onViewSourceClick, + }); + + return ; +}; + +const TileErrorViewWrapper = withViewDocs(TileErrorViewWrapperImpl, TileErrorView); + +const meta = { + title: "MessageBody/TileErrorView", + component: TileErrorViewWrapper, + tags: ["autodocs"], + decorators: [ + (Story): JSX.Element => ( +
      + +
    + ), + ], + args: { + message: "Can't load this message", + eventType: "m.room.message", + bugReportCtaLabel: "Submit debug logs", + viewSourceCtaLabel: "View source", + layout: "group", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const BubbleLayout: Story = { + args: { + layout: "bubble", + }, +}; + +export const WithoutActions: Story = { + args: { + bugReportCtaLabel: undefined, + viewSourceCtaLabel: undefined, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.test.tsx new file mode 100644 index 0000000000..514b9849a4 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 MouseEventHandler } from "react"; +import { composeStories } from "@storybook/react-vite"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@test-utils"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { + TileErrorView, + type TileErrorViewActions, + type TileErrorViewModel, + type TileErrorViewSnapshot, +} from "./TileErrorView"; +import * as stories from "./TileErrorView.stories"; + +const { Default, BubbleLayout, WithoutActions } = composeStories(stories); + +describe("TileErrorView", () => { + it("renders the default tile error state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the bubble layout variant", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the fallback text without actions", () => { + render(); + + expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Submit debug logs" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View source" })).not.toBeInTheDocument(); + }); + + it("invokes bug-report and view-source actions", async () => { + const user = userEvent.setup(); + const onBugReportClick = vi.fn(); + const onViewSourceClick = vi.fn(); + + class TestTileErrorViewModel extends MockViewModel implements TileErrorViewActions { + public onBugReportClick?: MouseEventHandler; + public onViewSourceClick?: MouseEventHandler; + + public constructor(snapshot: TileErrorViewSnapshot, actions: TileErrorViewActions) { + super(snapshot); + Object.assign(this, actions); + } + } + + const vm = new TestTileErrorViewModel( + { + layout: "group", + message: "Can't load this message", + eventType: "m.room.message", + bugReportCtaLabel: "Submit debug logs", + viewSourceCtaLabel: "View source", + }, + { + onBugReportClick, + onViewSourceClick, + }, + ) as TileErrorViewModel; + + render(); + + await user.click(screen.getByRole("button", { name: "Submit debug logs" })); + await user.click(screen.getByRole("button", { name: "View source" })); + + expect(onBugReportClick).toHaveBeenCalledTimes(1); + expect(onViewSourceClick).toHaveBeenCalledTimes(1); + }); + + it("applies a custom className to the root element", () => { + const vm = new MockViewModel({ + layout: "group", + message: "Can't load this message", + }) as TileErrorViewModel; + + render(); + + expect(screen.getByRole("status").closest("li")).toHaveClass("custom-tile-error"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.tsx new file mode 100644 index 0000000000..85e9939399 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.tsx @@ -0,0 +1,98 @@ +/* + * 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 MouseEventHandler } from "react"; +import classNames from "classnames"; +import { Button } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import styles from "./TileErrorView.module.css"; + +export type TileErrorViewLayout = "bubble" | "group" | "irc"; + +export interface TileErrorViewSnapshot { + /** + * Layout variant used by the host timeline. + */ + layout?: TileErrorViewLayout; + /** + * Primary fallback text shown when a tile fails to render. + */ + message: string; + /** + * Optional event type appended to the fallback text. + */ + eventType?: string; + /** + * Optional label for the bug-report action button. + */ + bugReportCtaLabel?: string; + /** + * Optional label for the view-source action. + */ + viewSourceCtaLabel?: string; +} + +export interface TileErrorViewActions { + /** + * Invoked when the bug-report button is clicked. + */ + onBugReportClick?: MouseEventHandler; + /** + * Invoked when the view-source action is clicked. + */ + onViewSourceClick?: MouseEventHandler; +} + +export type TileErrorViewModel = ViewModel; + +interface TileErrorViewProps { + /** + * The view model for the tile error fallback. + */ + vm: TileErrorViewModel; + /** + * Optional host-level class names. + */ + className?: string; +} + +/** + * Renders a timeline tile fallback when message content cannot be displayed. + * + * 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. + */ +export function TileErrorView({ vm, className }: Readonly): JSX.Element { + const { message, eventType, bugReportCtaLabel, viewSourceCtaLabel, layout = "group" } = useViewModel(vm); + + return ( +
  • +
    + + {message} + {eventType && ` (${eventType})`} + + {bugReportCtaLabel && ( + + )} + {viewSourceCtaLabel && ( + + )} +
    +
  • + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap new file mode 100644 index 0000000000..57ddb9b997 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/__snapshots__/TileErrorView.test.tsx.snap @@ -0,0 +1,77 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TileErrorView > renders the bubble layout variant 1`] = ` +
    +
      +
    • +
      + + Can't load this message + (m.room.message) + + + +
      +
    • +
    +
    +`; + +exports[`TileErrorView > renders the default tile error state 1`] = ` +
    +
      +
    • +
      + + Can't load this message + (m.room.message) + + + +
      +
    • +
    +
    +`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/index.tsx new file mode 100644 index 0000000000..a57eedc535 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/TileErrorView/index.tsx @@ -0,0 +1,14 @@ +/* + * 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 { + TileErrorView, + type TileErrorViewActions, + type TileErrorViewLayout, + type TileErrorViewModel, + type TileErrorViewSnapshot, +} from "./TileErrorView";