diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 4347d9a340..0c902fee3c 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -232,7 +232,6 @@ @import "./views/messages/_MStickerBody.pcss"; @import "./views/messages/_MediaBody.pcss"; @import "./views/messages/_MessageActionBar.pcss"; -@import "./views/messages/_MjolnirBody.pcss"; @import "./views/messages/_ReactionsRow.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; diff --git a/apps/web/res/css/views/messages/_MjolnirBody.pcss b/apps/web/res/css/views/messages/_MjolnirBody.pcss deleted file mode 100644 index 825eb36af6..0000000000 --- a/apps/web/res/css/views/messages/_MjolnirBody.pcss +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019 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. -*/ - -.mx_MjolnirBody { - opacity: 0.4; -} diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 58d7884a3f..409a9e0f97 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import mime from "mime"; -import React, { createRef, type JSX } from "react"; +import React, { createRef, type JSX, useEffect } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { EventType, @@ -18,7 +18,7 @@ import { M_POLL_START, type IContent, } from "matrix-js-sdk/src/matrix"; -import { UnknownBodyView } from "@element-hq/web-shared-components"; +import { MjolnirBodyView, UnknownBodyView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import SettingsStore from "../../../settings/SettingsStore"; import { Mjolnir } from "../../../mjolnir/Mjolnir"; @@ -30,9 +30,9 @@ import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; import MStickerBody from "./MStickerBody"; import MPollBody from "./MPollBody"; import MLocationBody from "./MLocationBody"; -import MjolnirBody from "./MjolnirBody"; import MBeaconBody from "./MBeaconBody"; import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; +import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel"; import { DecryptionFailureBodyFactory, FileBodyFactory, @@ -80,6 +80,20 @@ const baseEvTypes = new Map>([ [M_BEACON_INFO.altName, MBeaconBody], ]); +function MjolnirBodyWrappedView({ mxEvent, onMessageAllowed, ref }: IBodyProps): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new MjolnirBodyViewModel({ mxEvent, onMessageAllowed })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setOnMessageAllowed(onMessageAllowed); + }, [onMessageAllowed, vm]); + + return ; +} + function UnknownBody({ mxEvent, ref }: IBodyProps): JSX.Element { return ; } @@ -292,7 +306,7 @@ export default class MessageEvent extends React.Component implements IMe const serverBanned = userDomain && Mjolnir.sharedInstance().isServerBanned(userDomain); if (userBanned || serverBanned) { - BodyType = MjolnirBody; + BodyType = MjolnirBodyWrappedView; } } } diff --git a/apps/web/src/components/views/messages/MjolnirBody.tsx b/apps/web/src/components/views/messages/MjolnirBody.tsx deleted file mode 100644 index 4050e1750d..0000000000 --- a/apps/web/src/components/views/messages/MjolnirBody.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019 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 from "react"; - -import { _t } from "../../../languageHandler"; -import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import { type IBodyProps } from "./IBodyProps"; - -export default class MjolnirBody extends React.Component { - private onAllowClick = (e: ButtonEvent): void => { - e.preventDefault(); - e.stopPropagation(); - - const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; - localStorage.setItem(key, "true"); - this.props.onMessageAllowed?.(); - }; - - public render(): React.ReactNode { - return ( -
- - {_t( - "timeline|mjolnir|message_hidden", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - -
- ); - } -} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 34b3965c5e..74a978f700 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3588,7 +3588,6 @@ "created_rule_rooms": "%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", "created_rule_servers": "%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", "created_rule_users": "%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", - "message_hidden": "You have ignored this user, so their message is hidden. Show anyways.", "removed_rule": "%(senderName)s removed a ban rule matching %(glob)s", "removed_rule_rooms": "%(senderName)s removed the rule banning rooms matching %(glob)s", "removed_rule_servers": "%(senderName)s removed the rule banning servers matching %(glob)s", diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts new file mode 100644 index 0000000000..b64e554321 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts @@ -0,0 +1,65 @@ +/* + * 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 MouseEvent } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type MjolnirBodyViewModel as MjolnirBodyViewModelInterface, + type MjolnirBodyViewSnapshot, +} from "@element-hq/web-shared-components"; + +export interface MjolnirBodyViewModelProps { + /** + * The event currently hidden by Mjolnir. + */ + mxEvent: MatrixEvent; + /** + * Invoked after the event has been allowed so the tile can re-render. + */ + onMessageAllowed?: () => void; +} + +/** + * ViewModel for Mjolnir-hidden message bodies. + */ +export class MjolnirBodyViewModel + extends BaseViewModel + implements MjolnirBodyViewModelInterface +{ + private static readonly computeSnapshot = (): MjolnirBodyViewSnapshot => ({}); + + public constructor(props: MjolnirBodyViewModelProps) { + super(props, MjolnirBodyViewModel.computeSnapshot()); + } + + public setEvent(mxEvent: MatrixEvent): void { + if (this.props.mxEvent === mxEvent) return; + + // The view has no event-derived render state; this only changes action inputs. + this.props = { ...this.props, mxEvent }; + } + + public setOnMessageAllowed(onMessageAllowed: (() => void) | undefined): void { + if (this.props.onMessageAllowed === onMessageAllowed) return; + + // The view has no callback-derived render state; this only changes action inputs. + this.props = { ...this.props, onMessageAllowed }; + } + + public onAllowClick = (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + localStorage.setItem(this.localStorageKey, "true"); + this.props.onMessageAllowed?.(); + }; + + private get localStorageKey(): string { + return `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; + } +} diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx index 667a3c50b3..476a1ba666 100644 --- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { render, type RenderResult } from "jest-matrix-react"; +import { fireEvent, render, type RenderResult } from "jest-matrix-react"; import { type MatrixClient, type MatrixEvent, EventType, type Room, MsgType } from "matrix-js-sdk/src/matrix"; import fetchMock from "@fetch-mock/jest"; import fs from "fs"; @@ -18,6 +18,7 @@ import { mkEvent, mkRoom, stubClient } from "../../../../test-utils"; import MessageEvent from "../../../../../src/components/views/messages/MessageEvent"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir"; jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({ __esModule: true, @@ -78,6 +79,7 @@ describe("MessageEvent", () => { }; beforeEach(() => { + localStorage.clear(); client = stubClient(); room = mkRoom(client, "!room:example.com"); jest.spyOn(client, "getRoom").mockReturnValue(room); @@ -86,6 +88,11 @@ describe("MessageEvent", () => { jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn()); }); + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + it("renders the shared redacted body for redacted events", () => { jest.spyOn(room, "getMember").mockReturnValue({ name: "Moderator" } as any); event = mkEvent({ @@ -113,6 +120,34 @@ describe("MessageEvent", () => { expect(result.queryByTestId("textual-body")).toBeNull(); }); + it("renders the shared Mjolnir body for banned senders", () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settingName === "feature_mjolnir"); + jest.spyOn(Mjolnir, "sharedInstance").mockReturnValue({ + isUserBanned: jest.fn().mockReturnValue(true), + isServerBanned: jest.fn().mockReturnValue(false), + } as unknown as Mjolnir); + event = mkEvent({ + event: true, + type: EventType.RoomMessage, + id: "$hidden:example.com", + user: "@alice:example.com", + room: room.roomId, + content: { + msgtype: MsgType.Text, + body: "Hidden", + }, + }); + + const result = renderMessageEvent(); + + expect(result.getByText(/You have ignored this user, so their message is hidden\./)).toBeInTheDocument(); + const allowButton = result.getByRole("button", { name: "Show anyways." }); + + fireEvent.click(allowButton); + + expect(localStorage.getItem(`mx_mjolnir_render_${room.roomId}__$hidden:example.com`)).toBe("true"); + }); + it("renders the shared unknown body for unsupported message types", () => { event = mkEvent({ event: true, diff --git a/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts b/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts new file mode 100644 index 0000000000..c93aa3c2b6 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts @@ -0,0 +1,84 @@ +/* + * 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 MouseEvent } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { MjolnirBodyViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel"; + +describe("MjolnirBodyViewModel", () => { + const createEvent = (roomId = "!room:example.com", eventId = "$event:example.com"): MatrixEvent => + ({ + getRoomId: jest.fn().mockReturnValue(roomId), + getId: jest.fn().mockReturnValue(eventId), + }) as unknown as MatrixEvent; + + const createClickEvent = (): MouseEvent => + ({ + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }) as unknown as MouseEvent; + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it("has an empty snapshot", () => { + const vm = new MjolnirBodyViewModel({ mxEvent: createEvent() }); + + expect(vm.getSnapshot()).toEqual({}); + }); + + it("allows rendering the hidden event and notifies the parent", () => { + const onMessageAllowed = jest.fn(); + const vm = new MjolnirBodyViewModel({ + mxEvent: createEvent("!room:example.com", "$hidden:example.com"), + onMessageAllowed, + }); + const event = createClickEvent(); + + vm.onAllowClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(localStorage.getItem("mx_mjolnir_render_!room:example.com__$hidden:example.com")).toBe("true"); + expect(onMessageAllowed).toHaveBeenCalledTimes(1); + }); + + it("uses the updated event and callback", () => { + const oldCallback = jest.fn(); + const newCallback = jest.fn(); + const vm = new MjolnirBodyViewModel({ + mxEvent: createEvent("!old:example.com", "$old:example.com"), + onMessageAllowed: oldCallback, + }); + + vm.setEvent(createEvent("!new:example.com", "$new:example.com")); + vm.setOnMessageAllowed(newCallback); + vm.onAllowClick(createClickEvent()); + + expect(localStorage.getItem("mx_mjolnir_render_!old:example.com__$old:example.com")).toBeNull(); + expect(localStorage.getItem("mx_mjolnir_render_!new:example.com__$new:example.com")).toBe("true"); + expect(oldCallback).not.toHaveBeenCalled(); + expect(newCallback).toHaveBeenCalledTimes(1); + }); + + it("does not emit snapshot updates for unchanged action inputs", () => { + const mxEvent = createEvent(); + const onMessageAllowed = jest.fn(); + const listener = jest.fn(); + const vm = new MjolnirBodyViewModel({ mxEvent, onMessageAllowed }); + + vm.subscribe(listener); + + vm.setEvent(mxEvent); + vm.setOnMessageAllowed(onMessageAllowed); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..275a8302e6 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 4ced2b9a7a..3bb8915e14 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -237,6 +237,9 @@ }, "message_timestamp_received_at": "Received at: %(dateTime)s", "message_timestamp_sent_at": "Sent at: %(dateTime)s", + "mjolnir": { + "message_hidden": "You have ignored this user, so their message is hidden. Show anyways." + }, "pending_moderation": "Message pending moderation", "pending_moderation_reason": "Message pending moderation: %(reason)s", "url_preview": { diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 95953c6a83..7e69cd9369 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -23,6 +23,7 @@ export * from "./room/timeline/event-tile/body/HiddenMediaPlaceholder"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; export * from "./room/timeline/event-tile/body/MImageBodyView"; +export * from "./room/timeline/event-tile/body/MjolnirBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/body/UnknownBodyView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.module.css new file mode 100644 index 0000000000..681b8036e9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.module.css @@ -0,0 +1,26 @@ +/* + * 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. + */ + +.content { + /* Avoid reducing opacity here: it lowers the effective contrast of both text and inline controls, failing axe tests. */ + color: var(--cpd-color-text-secondary); +} + +.allowButton { + border: 0; + padding: 0; + background: transparent; + color: var(--cpd-color-text-action-accent); + cursor: pointer; + font: inherit; + text-decoration: underline; +} + +.allowButton:hover, +.allowButton:focus-visible { + text-decoration-thickness: var(--cpd-space-0-5x); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx new file mode 100644 index 0000000000..860584cb87 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { MjolnirBodyView, type MjolnirBodyViewActions, type MjolnirBodyViewSnapshot } from "./MjolnirBodyView"; + +type MjolnirBodyViewProps = MjolnirBodyViewSnapshot & + MjolnirBodyViewActions & { + className?: string; + }; + +const MjolnirBodyViewWrapperImpl = ({ onAllowClick, className, ...snapshot }: MjolnirBodyViewProps): JSX.Element => { + const vm = useMockedViewModel(snapshot, { onAllowClick }); + + return ; +}; + +const MjolnirBodyViewWrapper = withViewDocs(MjolnirBodyViewWrapperImpl, MjolnirBodyView); + +const meta = { + title: "Timeline/Timeline Body/MjolnirBodyView", + component: MjolnirBodyViewWrapper, + tags: ["autodocs"], + args: { + onAllowClick: fn(), + className: "", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.test.tsx new file mode 100644 index 0000000000..f74b2fe112 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.test.tsx @@ -0,0 +1,71 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { + MjolnirBodyView, + type MjolnirBodyViewActions, + type MjolnirBodyViewModel, + type MjolnirBodyViewSnapshot, +} from "./MjolnirBodyView"; +import * as stories from "./MjolnirBodyView.stories"; + +const { Default } = composeStories(stories); + +class TestMjolnirBodyViewModel extends MockViewModel implements MjolnirBodyViewActions { + public constructor( + snapshot: MjolnirBodyViewSnapshot, + public onAllowClick: MjolnirBodyViewActions["onAllowClick"], + ) { + super(snapshot); + } +} + +describe("MjolnirBodyView", () => { + it("renders the default story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText(/You have ignored this user, so their message is hidden\./)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show anyways." })).toBeInTheDocument(); + }); + + it("invokes the allow action", async () => { + const user = userEvent.setup(); + const onAllowClick = vi.fn(); + const vm = new TestMjolnirBodyViewModel({}, onAllowClick) as MjolnirBodyViewModel; + + render(); + + await user.click(screen.getByRole("button", { name: "Show anyways." })); + + expect(onAllowClick).toHaveBeenCalledTimes(1); + }); + + it("applies a custom className to the root element", () => { + const vm = new TestMjolnirBodyViewModel({}, vi.fn()) as MjolnirBodyViewModel; + + const { container } = render(); + + expect(container.firstChild).toHaveClass("custom-mjolnir"); + }); + + it("forwards the provided ref to the root element", () => { + const ref = React.createRef(); + const vm = new TestMjolnirBodyViewModel({}, vi.fn()) as MjolnirBodyViewModel; + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.tsx new file mode 100644 index 0000000000..b0995d5647 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.tsx @@ -0,0 +1,65 @@ +/* + * 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 classNames from "classnames"; +import React, { type JSX, type MouseEventHandler, type Ref } from "react"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import { useI18n } from "../../../../../core/i18n/i18nContext"; +import styles from "./MjolnirBodyView.module.css"; + +export type MjolnirBodyViewSnapshot = Record; + +export interface MjolnirBodyViewActions { + /** + * Invoked when the user chooses to show the hidden message. + */ + onAllowClick: MouseEventHandler; +} + +export type MjolnirBodyViewModel = ViewModel; + +interface MjolnirBodyViewProps { + /** + * ViewModel providing the action handler. + */ + vm: MjolnirBodyViewModel; + /** + * Optional CSS class names applied to the root element. + */ + className?: string; + /** + * Optional ref forwarded to the root element. + */ + ref?: Ref; +} + +/** + * Renders the placeholder shown when a message is hidden because its sender is ignored. + */ +export function MjolnirBodyView({ vm, className, ref }: Readonly): JSX.Element { + useViewModel(vm); + const _t = useI18n().translate; + + return ( +
+ + {_t( + "timeline|mjolnir|message_hidden", + {}, + { + a: (sub) => ( + + ), + }, + )} + +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/__snapshots__/MjolnirBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/__snapshots__/MjolnirBodyView.test.tsx.snap new file mode 100644 index 0000000000..c9a248bcbb --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/__snapshots__/MjolnirBodyView.test.tsx.snap @@ -0,0 +1,21 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MjolnirBodyView > renders the default story 1`] = ` +
+
+ + + You have ignored this user, so their message is hidden. + + + +
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/index.tsx new file mode 100644 index 0000000000..bbe47bd4bb --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/index.tsx @@ -0,0 +1,13 @@ +/* + * 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 { + MjolnirBodyView, + type MjolnirBodyViewActions, + type MjolnirBodyViewModel, + type MjolnirBodyViewSnapshot, +} from "./MjolnirBodyView";