diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 967b37e1a9..2705a483a9 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, useEffect } from "react"; +import React, { createRef, type JSX, useCallback, useEffect } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { EventType, @@ -32,6 +32,10 @@ import MLocationBody from "./MLocationBody"; import MBeaconBody from "./MBeaconBody"; import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel"; +import { + allowMjolnirBody, + isMjolnirBodyAllowed, +} from "../../../viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState"; import { DecryptionFailureBodyFactory, FileBodyFactory, @@ -81,15 +85,14 @@ const baseEvTypes = new Map>([ ]); function MjolnirBodyWrappedView({ mxEvent, onMessageAllowed, ref }: IBodyProps): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new MjolnirBodyViewModel({ mxEvent, onMessageAllowed })); + const onAllow = useCallback(() => { + allowMjolnirBody(mxEvent, onMessageAllowed); + }, [mxEvent, onMessageAllowed]); + const vm = useCreateAutoDisposedViewModel(() => new MjolnirBodyViewModel({ onAllow })); useEffect(() => { - vm.setEvent(mxEvent); - }, [mxEvent, vm]); - - useEffect(() => { - vm.setOnMessageAllowed(onMessageAllowed); - }, [onMessageAllowed, vm]); + vm.setProps({ onAllow }); + }, [onAllow, vm]); return ; } @@ -297,10 +300,7 @@ export default class MessageEvent extends React.Component implements IMe } if (SettingsStore.getValue("feature_mjolnir")) { - const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; - const allowRender = localStorage.getItem(key) === "true"; - - if (!allowRender) { + if (!isMjolnirBodyAllowed(this.props.mxEvent)) { const userDomain = this.props.mxEvent.getSender()?.split(":").slice(1).join(":"); const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender()!); const serverBanned = userDomain && Mjolnir.sharedInstance().isServerBanned(userDomain); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.test.ts new file mode 100644 index 0000000000..30543ed13a --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.test.ts @@ -0,0 +1,53 @@ +/* +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. +*/ + +// @vitest-environment happy-dom + +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { allowMjolnirBody, getMjolnirBodyStorageKey, isMjolnirBodyAllowed } from "./EventTileMjolnirBodyState"; + +describe("EventTileMjolnirBodyState", () => { + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("derives the application storage key from an event", () => { + const event = new MatrixEvent({ + room_id: "!room:example.org", + event_id: "$event:example.org", + }); + + expect(getMjolnirBodyStorageKey(event)).toBe("mx_mjolnir_render_!room:example.org__$event:example.org"); + }); + + it("reads whether an event has been allowed", () => { + const event = new MatrixEvent({ + room_id: "!room:example.org", + event_id: "$event:example.org", + }); + + expect(isMjolnirBodyAllowed(event)).toBe(false); + allowMjolnirBody(event); + expect(isMjolnirBodyAllowed(event)).toBe(true); + }); + + it("stores the allow decision and notifies the owning tile", () => { + const event = new MatrixEvent({ + room_id: "!room:example.org", + event_id: "$event:example.org", + }); + const onMessageAllowed = vi.fn(); + + allowMjolnirBody(event, onMessageAllowed); + + expect(localStorage.getItem("mx_mjolnir_render_!room:example.org__$event:example.org")).toBe("true"); + expect(onMessageAllowed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.ts new file mode 100644 index 0000000000..e75e4a3a1e --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileMjolnirBodyState.ts @@ -0,0 +1,24 @@ +/* +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 MatrixEvent } from "matrix-js-sdk/src/matrix"; + +/** Gets the application storage key used to remember that an event may be shown. */ +export function getMjolnirBodyStorageKey(mxEvent: MatrixEvent): string { + return `mx_mjolnir_render_${mxEvent.getRoomId()}__${mxEvent.getId()}`; +} + +/** Gets whether a Mjolnir-hidden event has been allowed to render. */ +export function isMjolnirBodyAllowed(mxEvent: MatrixEvent): boolean { + return localStorage.getItem(getMjolnirBodyStorageKey(mxEvent)) === "true"; +} + +/** Allows a Mjolnir-hidden event to be shown and notifies the owning tile. */ +export function allowMjolnirBody(mxEvent: MatrixEvent, onMessageAllowed?: () => void): void { + localStorage.setItem(getMjolnirBodyStorageKey(mxEvent), "true"); + onMessageAllowed?.(); +} 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 index b64e554321..df68a52528 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts @@ -5,8 +5,6 @@ * 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, @@ -14,14 +12,8 @@ import { } 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; + /** Invoked when the user chooses to show the hidden event. */ + onAllow: () => void; } /** @@ -37,29 +29,11 @@ export class MjolnirBodyViewModel 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 setProps(props: MjolnirBodyViewModelProps): void { + this.props = props; } - 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?.(); + public onAllow = (): void => { + this.props.onAllow(); }; - - private get localStorageKey(): string { - return `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; - } } diff --git a/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts b/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts index c93aa3c2b6..5d22deb4d4 100644 --- a/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts +++ b/apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts @@ -5,79 +5,44 @@ * 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() }); + const vm = new MjolnirBodyViewModel({ onAllow: jest.fn() }); 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(); + it("forwards the allow action", () => { + const onAllow = jest.fn(); + const vm = new MjolnirBodyViewModel({ onAllow }); - vm.onAllowClick(event); + vm.onAllow(); - 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); + expect(onAllow).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, - }); + it("uses the updated action", () => { + const oldAction = jest.fn(); + const newAction = jest.fn(); + const vm = new MjolnirBodyViewModel({ onAllow: oldAction }); - vm.setEvent(createEvent("!new:example.com", "$new:example.com")); - vm.setOnMessageAllowed(newCallback); - vm.onAllowClick(createClickEvent()); + vm.setProps({ onAllow: newAction }); + vm.onAllow(); - 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); + expect(oldAction).not.toHaveBeenCalled(); + expect(newAction).toHaveBeenCalledTimes(1); }); it("does not emit snapshot updates for unchanged action inputs", () => { - const mxEvent = createEvent(); - const onMessageAllowed = jest.fn(); + const props = { onAllow: jest.fn() }; const listener = jest.fn(); - const vm = new MjolnirBodyViewModel({ mxEvent, onMessageAllowed }); + const vm = new MjolnirBodyViewModel(props); vm.subscribe(listener); - vm.setEvent(mxEvent); - vm.setOnMessageAllowed(onMessageAllowed); + vm.setProps(props); expect(listener).not.toHaveBeenCalled(); }); 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 index 860584cb87..90bee47272 100644 --- 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 @@ -18,8 +18,8 @@ type MjolnirBodyViewProps = MjolnirBodyViewSnapshot & className?: string; }; -const MjolnirBodyViewWrapperImpl = ({ onAllowClick, className, ...snapshot }: MjolnirBodyViewProps): JSX.Element => { - const vm = useMockedViewModel(snapshot, { onAllowClick }); +const MjolnirBodyViewWrapperImpl = ({ onAllow, className, ...snapshot }: MjolnirBodyViewProps): JSX.Element => { + const vm = useMockedViewModel(snapshot, { onAllow }); return ; }; @@ -31,7 +31,7 @@ const meta = { component: MjolnirBodyViewWrapper, tags: ["autodocs"], args: { - onAllowClick: fn(), + onAllow: fn(), className: "", }, } satisfies Meta; 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 index f74b2fe112..597b97a910 100644 --- 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 @@ -25,7 +25,7 @@ const { Default } = composeStories(stories); class TestMjolnirBodyViewModel extends MockViewModel implements MjolnirBodyViewActions { public constructor( snapshot: MjolnirBodyViewSnapshot, - public onAllowClick: MjolnirBodyViewActions["onAllowClick"], + public onAllow: MjolnirBodyViewActions["onAllow"], ) { super(snapshot); } @@ -42,14 +42,15 @@ describe("MjolnirBodyView", () => { it("invokes the allow action", async () => { const user = userEvent.setup(); - const onAllowClick = vi.fn(); - const vm = new TestMjolnirBodyViewModel({}, onAllowClick) as MjolnirBodyViewModel; + const onAllow = vi.fn(); + const vm = new TestMjolnirBodyViewModel({}, onAllow) as MjolnirBodyViewModel; render(); await user.click(screen.getByRole("button", { name: "Show anyways." })); - expect(onAllowClick).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledTimes(1); + expect(onAllow).toHaveBeenCalledWith(); }); it("applies a custom className to the root element", () => { 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 index b0995d5647..7a793b4926 100644 --- 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 @@ -6,7 +6,7 @@ */ import classNames from "classnames"; -import React, { type JSX, type MouseEventHandler, type Ref } from "react"; +import React, { type JSX, type Ref } from "react"; import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; import { useI18n } from "../../../../../core/i18n/i18nContext"; @@ -18,7 +18,7 @@ export interface MjolnirBodyViewActions { /** * Invoked when the user chooses to show the hidden message. */ - onAllowClick: MouseEventHandler; + onAllow: () => void; } export type MjolnirBodyViewModel = ViewModel; @@ -53,7 +53,15 @@ export function MjolnirBodyView({ vm, className, ref }: Readonly ( - ),