From cf9cdbbc868945e5702e7d5de2bb491e2dc92825 Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 7 May 2026 15:45:43 +0200 Subject: [PATCH] Refactor Mjolnir body to shared view (#33407) * Refactor Mjolnir body to shared view * Update compund css + add snapshot * Remove from app, and add in shared components * update css to fix axe fail issue --- apps/web/res/css/_components.pcss | 1 - .../res/css/views/messages/_MjolnirBody.pcss | 11 --- .../views/messages/MessageEvent.tsx | 22 ++++- .../components/views/messages/MjolnirBody.tsx | 44 --------- apps/web/src/i18n/strings/en_EN.json | 1 - .../event-tile/body/MjolnirBodyViewModel.ts | 65 ++++++++++++++ .../views/messages/MessageEvent-test.tsx | 37 +++++++- .../message-body/MjolnirBodyViewModel-test.ts | 84 ++++++++++++++++++ .../default-auto.png | Bin 0 -> 25106 bytes .../src/i18n/strings/en_EN.json | 3 + packages/shared-components/src/index.ts | 1 + .../MjolnirBodyView.module.css | 26 ++++++ .../MjolnirBodyView.stories.tsx | 42 +++++++++ .../MjolnirBodyView/MjolnirBodyView.test.tsx | 71 +++++++++++++++ .../body/MjolnirBodyView/MjolnirBodyView.tsx | 65 ++++++++++++++ .../MjolnirBodyView.test.tsx.snap | 21 +++++ .../event-tile/body/MjolnirBodyView/index.tsx | 13 +++ 17 files changed, 445 insertions(+), 62 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_MjolnirBody.pcss delete mode 100644 apps/web/src/components/views/messages/MjolnirBody.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/body/MjolnirBodyViewModel.ts create mode 100644 apps/web/test/viewmodels/message-body/MjolnirBodyViewModel-test.ts create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.module.css create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/MjolnirBodyView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/__snapshots__/MjolnirBodyView.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/MjolnirBodyView/index.tsx 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 0000000000000000000000000000000000000000..275a8302e67f5b5d556ef41ae850659ce3e0b7af GIT binary patch literal 25106 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

Gg0bzj`E{0X*licJP2d2H38& zFNKCQRDiduK(1?#;-WAqfn-cx&u~^gSAD$=DK*mJH|4{I7oz4H?{p z!~#OiTw@CML_Jp2NKuJsU?1%e;S14W{#_^!S%&Ij_U#9vCsSxO^Sqn zAgaB%<47(+r|1JP$n*ffa~vu82F#FtqwX&QytnLeq}F}466juvhNQzjmb^|%)Dwx5 z7;=k|AP?u-VH@aH3H{0`sDBAF03UT|phj)PD^;@%grW%9u)p1lhRt-$p??f>&5)|X z`i6#8i5|=~C&&Yyc554%ucbgQPY{>AV7)>kbr_r~0v=tW0Xi4&twDEvWUYS)xa>Kf zcwcw*NMs;XtpaOePW;al3|k~j)V zC~!uf*$GO9F;tQyUD2q0W~Y+?4@@?t!`Uc(W+x~D#`sf$8E`IgpZO@#>(n#qVsb=W zNS}EZSij=>*D!rRI~jiP2V?u&po6U-zbaI+@nDIy=v(v>udvf_M@TyP=s`j!;Gm0>^|_s+KEF* ztK$XVb$afM^#%CG_d`#R;C@o1o1aPnwjbctEkl$`$iOpLYVJ#X0mgaC-I;3O>y5CK z$(ULjh=BDUkTR5#CP)y;dXBU^#xQSp)7fIo{{0jXI|jsdILMRBS&G#VSg-A&k2N!e zx{}wJF>biaql`5(vCJGYJjPfv{Qx#DbxcPwZLFC|9#hc5Lg%iVRG<@2#XteimInFsebBQw2N;yRGgrrHgssrdqYRoDW79XvX8 z$J>J#Ep{U`Wbj050M`s`I781aVD7}~3^5GAQqw`S1|2XS8_iOgRSw_~KKNjP#u50D z=!+e<-jAB~-e5o#KUt*E9OoLaL5+P_T2CzisbI?V$iSD-V@&o12vRRn8fo+TRAJ{q z(}aAHLY>#A`PE@S<^Q=zfvM|f_Ge&nojk8kJyIF6C{Gp~a<|lg%SB1S|I}#FSB$oYrR6g`tI9Q~;5MW;@h2b17th0qGl) z=sHVjGgAkepK(!_gBFt8%(Q`K#%LQBX*2852b!5!Zg7I=mO&e6rV9@Aa3hKeNV+=W0-^VKV~i7T>L;Y%;LkXYDJD2eH#X%V6TH2{v0 zB4FsH3*~4YFmlNc2jEJdaDX$M-U-4U10Qlr{LVqxQ&$U}LGxdbp@GF2Q$_=rW@V6M z@wkMq1qB6XmP(3ALZ*k3Z1ty&7|>>AuxLOABkBK;rij0`!iTNUz6KY~=XlY8p33MN zqMc#V^c)E3jphq!2G@Ua>&n2i16zrj>8>m$udBNkowd~j4xPj7Uv<~>i%Zly6x#aD z>f|hzuIsgH3BhZ- z7hn=Lk2WlCe<*q%JI`9l5H|Fxp*NoD4>_VJrD~xd#Bl%^;PmffU;yC;M`9$|%Yg(; zfC0HzUmQdjAR2|I&V&<=0|V}w3Y3x=K={L!NEu(@f$0Fr1&&u#28dF>#{?zETxZ{U z+K-AK!Gbdpxl;KBx?Yj4%}9OWMk5c-34)eDWSkzO^-AbrPrQ$f07?(0JTa!(A%fCF zys-w9ykXbgKVFqqTChD%<3Q)$yc%!8kR80he;0dU&2P~%S8`*I=32nF`a`Txu~KG| zD5x&wCPn+5hCa1qp)kuI>Q>|?#fVn2P@Idd8!(G}lTym3yPN88fS(5;Y+XLLM-Dy= zzJzAz%wr^md^x@!@yewFiqBfepf})2xb{Nn-G^Nq3YYeDoY9>&e7nw?EH1D)~ zjX|N%KFI$^Ko~yq05VLK8S_=3tI%`~MU;LXbAVPQQ(oRIN)m-Z^5$A{aG~p;IX}jd zqvYd&8S`ZjLMQSK?^H!MGSbfwiYPJ=`MmUi;j5%oD*p}?;YLcBZGbS5{DlkES8D77 z+U&pZoFUF)4$%FlkS_8eV_Ry!F*n z#02sr%$2BOfuiR6t1z7jGJ-@;%t?x8x(qc|MbRzfi+1u0yUo`lFC^jtb%NR4hJNTd%3lbYbiT0|6U ZP;nhXH!Rq>?gIpMT21@!)Z^y2{tvi!n literal 0 HcmV?d00001 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";