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 0000000000..494ff51eb4 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/bubble-layout-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..669e4f1ff3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/without-actions-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/without-actions-auto.png new file mode 100644 index 0000000000..13bcb57c9a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/TileErrorView/TileErrorView.stories.tsx/without-actions-auto.png differ 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";