diff --git a/apps/web/src/components/views/messages/MKeyVerificationRequest.tsx b/apps/web/src/components/views/messages/MKeyVerificationRequest.tsx deleted file mode 100644 index 6765f330da..0000000000 --- a/apps/web/src/components/views/messages/MKeyVerificationRequest.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 , 2023 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 JSX } from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { EventTileBubble } from "@element-hq/web-shared-components"; - -import { _t } from "../../../languageHandler"; -import { getNameForEventRoom, userLabelForEventRoom } from "../../../utils/KeyVerificationStateObserver"; -import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; - -interface Props { - mxEvent: MatrixEvent; - timestamp?: JSX.Element; -} - -interface MKeyVerificationRequestContent { - body?: string; - format?: string; - formatted_body?: string; - from_device: string; - methods: Array; - msgtype: "m.key.verification.request"; - to: string; -} - -/** - * Event tile created when we receive an m.key.verification.request event. - * - * Displays a simple message saying that a verification was requested, either by - * this user or someone else. - * - * EventTileFactory has logic meaning we only display this tile if the request - * was sent to/from this user. - */ -const MKeyVerificationRequest: React.FC = ({ mxEvent, timestamp }) => { - const client = useMatrixClientContext(); - - if (!client) { - throw new Error("Attempting to render verification request without a client context!"); - } - - const myUserId = client.getSafeUserId(); - const content: MKeyVerificationRequestContent = mxEvent.getContent(); - const sender = mxEvent.getSender(); - const receiver = content.to; - const roomId = mxEvent.getRoomId(); - - if (!sender) { - throw new Error("Verification request did not include a sender!"); - } - if (!roomId) { - throw new Error("Verification request did not include a room ID!"); - } - - let title: string; - let subtitle: string; - - const sentByMe = sender === myUserId; - if (sentByMe) { - title = _t("timeline|m.key.verification.request|you_started"); - subtitle = userLabelForEventRoom(client, receiver, roomId); - } else { - const name = getNameForEventRoom(client, sender, roomId); - title = _t("timeline|m.key.verification.request|user_wants_to_verify", { name }); - subtitle = userLabelForEventRoom(client, sender, roomId); - } - - return ( - } - className="mx_EventTileBubble mx_cryptoEvent" - title={title} - subtitle={subtitle} - > - {timestamp} - - ); -}; - -export default MKeyVerificationRequest; diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 19dba1da0d..152a69c1ec 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -20,6 +20,7 @@ import { import { EncryptionEventView, HiddenBodyView, + MKeyVerificationRequestView, TextualEventView, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; @@ -37,7 +38,6 @@ import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore"; import { ALL_RULE_TYPES } from "../mjolnir/BanList"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; -import MKeyVerificationRequest from "../components/views/messages/MKeyVerificationRequest"; import { WidgetType } from "../widgets/WidgetType"; import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent"; import { hasText } from "../TextForEvent"; @@ -47,6 +47,7 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; import { ModuleApi } from "../modules/Api"; import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel"; +import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel"; import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel"; import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; import { ElementCallEventType } from "../call-types"; @@ -95,7 +96,21 @@ function EncryptionEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { const EncryptionEventFactory: Factory = (ref, props) => { return ; }; -const VerificationReqFactory: Factory = (_ref, props) => ; +function MKeyVerificationRequestWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const cli = useMatrixClientContext(); + if (!cli) { + throw new Error("Attempting to render verification request without a client context!"); + } + + const vm = useCreateAutoDisposedViewModel(() => new MKeyVerificationRequestViewModel({ mxEvent, cli })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + return ; +} +const VerificationReqFactory: Factory = (ref, props) => ; function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new HiddenBodyViewModel({ mxEvent })); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel.ts new file mode 100644 index 0000000000..5d4de14f0f --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel.ts @@ -0,0 +1,101 @@ +/* + * 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 JSX } from "react"; +import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type MKeyVerificationRequestViewModel as MKeyVerificationRequestViewModelInterface, + type MKeyVerificationRequestViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../../../languageHandler"; +import { getNameForEventRoom, userLabelForEventRoom } from "../../../../utils/KeyVerificationStateObserver"; + +interface MKeyVerificationRequestContent { + body?: string; + format?: string; + formatted_body?: string; + from_device: string; + methods: string[]; + msgtype: "m.key.verification.request"; + to: string; +} + +export interface MKeyVerificationRequestViewModelProps { + /** + * Caller-provided client. + */ + cli: MatrixClient; + /** + * Verification request message event. + */ + mxEvent: MatrixEvent; + /** + * Optional timestamp element rendered in the tile footer slot. + */ + timestamp?: JSX.Element; +} + +/** + * ViewModel for key verification request message events. + */ +export class MKeyVerificationRequestViewModel + extends BaseViewModel + implements MKeyVerificationRequestViewModelInterface +{ + public constructor(props: MKeyVerificationRequestViewModelProps) { + super(props, MKeyVerificationRequestViewModel.computeSnapshot(props)); + } + + public setEvent(mxEvent: MatrixEvent): void { + this.props = { ...this.props, mxEvent }; + this.updateSnapshotFromProps(); + } + + public setTimestamp(timestamp: JSX.Element | undefined): void { + this.props = { ...this.props, timestamp }; + this.snapshot.merge({ timestamp }); + } + + private updateSnapshotFromProps(): void { + this.snapshot.merge(MKeyVerificationRequestViewModel.computeSnapshot(this.props)); + } + + private static computeSnapshot({ + cli, + mxEvent, + timestamp, + }: MKeyVerificationRequestViewModelProps): MKeyVerificationRequestViewSnapshot { + const myUserId = cli.getSafeUserId(); + const content = mxEvent.getContent(); + const sender = mxEvent.getSender(); + const roomId = mxEvent.getRoomId(); + + if (!sender) { + throw new Error("Verification request did not include a sender!"); + } + if (!roomId) { + throw new Error("Verification request did not include a room ID!"); + } + + if (sender === myUserId) { + return { + title: _t("timeline|m.key.verification.request|you_started"), + subtitle: userLabelForEventRoom(cli, content.to, roomId), + timestamp, + }; + } + + const name = getNameForEventRoom(cli, sender, roomId); + return { + title: _t("timeline|m.key.verification.request|user_wants_to_verify", { name }), + subtitle: userLabelForEventRoom(cli, sender, roomId), + timestamp, + }; + } +} 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 deleted file mode 100644 index 0265bd44d1..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/MKeyVerificationRequest-test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 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 from "react"; -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 MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { filterConsole } from "../../../../test-utils"; - -describe("MKeyVerificationRequest", () => { - filterConsole( - "The above error occurred in the component", - "Error: Attempting to render verification request without a client context!", - "Error: Verification request did not include a sender!", - "Error: Verification request did not include a room ID!", - ); - - it("shows an error if not wrapped in a client context", () => { - const event = new MatrixEvent({ type: "m.key.verification.request" }); - const { container } = renderEventNoClient(event); - expect(container).toHaveTextContent("Can't load this message"); - }); - - it("shows an error if the event has no sender", () => { - const { client } = setup(); - const event = new MatrixEvent({ type: "m.key.verification.request" }); - const { container } = renderEvent(client, event); - expect(container).toHaveTextContent("Can't load this message"); - }); - - it("shows an error if the event has no room", () => { - const { client } = setup(); - const event = new MatrixEvent({ type: "m.key.verification.request", sender: "@a:b.co" }); - const { container } = renderEvent(client, event); - expect(container).toHaveTextContent("Can't load this message"); - }); - - it("displays a request from me", () => { - const { client, myUserId } = setup(); - const event = new MatrixEvent({ type: "m.key.verification.request", sender: myUserId, room_id: "!x:y.co" }); - const { container } = renderEvent(client, event); - expect(container).toHaveTextContent("You sent a verification request"); - }); - - it("displays a request from someone else to me", () => { - const otherUserId = "@other:s.uk"; - const { client } = setup(); - const event = new MatrixEvent({ type: "m.key.verification.request", sender: otherUserId, room_id: "!x:y.co" }); - const { container } = renderEvent(client, event); - expect(container).toHaveTextContent("other:s.uk wants to verify"); - }); -}); - -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( - - - - - , - , - ); -} - -function setup(): { client: MatrixClient; myUserId: string } { - const myUserId = "@me:s.co"; - - const client = { - getSafeUserId: jest.fn().mockReturnValue(myUserId), - getRoom: jest.fn(), - } as unknown as MatrixClient; - - return { client, myUserId }; -} diff --git a/apps/web/test/unit-tests/events/EventTileFactory-test.ts b/apps/web/test/unit-tests/events/EventTileFactory-test.ts index b1ff4c57bf..f37e30667f 100644 --- a/apps/web/test/unit-tests/events/EventTileFactory-test.ts +++ b/apps/web/test/unit-tests/events/EventTileFactory-test.ts @@ -6,8 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import React from "react"; +import { render, screen } from "jest-matrix-react"; import { mocked } from "jest-mock"; -import { EventType, type MatrixClient, MatrixEvent, MsgType, Room } from "matrix-js-sdk/src/matrix"; +import { EventType, type MatrixClient, MatrixEvent, MsgType, Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { JSONEventFactory, @@ -20,9 +22,25 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { createTestClient, mkEvent } from "../../test-utils"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { ModuleApi } from "../../../src/modules/Api"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; const roomId = "!room:example.com"; +function makeVerificationRequestEvent({ sender, to }: { sender: string; to: string }): MatrixEvent { + return mkEvent({ + event: true, + type: EventType.RoomMessage, + user: sender, + room: roomId, + content: { + msgtype: MsgType.KeyVerificationRequest, + from_device: "DEVICE", + methods: ["m.sas.v1"], + to, + }, + }); +} + describe("pickFactory", () => { let client: MatrixClient; let room: Room; @@ -206,14 +224,30 @@ describe("pickFactory", () => { it("should return a MessageEventFactory for a UTD event", () => { expect(pickFactory(utdEvent, client, false)).toBe(MessageEventFactory); }); + + it("should not render key verification requests which do not involve the current user", () => { + const event = makeVerificationRequestEvent({ + sender: "@alice:example.com", + to: "@bob:example.com", + }); + + expect(pickFactory(event, client, false)).toBeUndefined(); + }); }); }); describe("renderTile", () => { let client: MatrixClient; + let originalRenderMessage: typeof ModuleApi.instance.customComponents.renderMessage; beforeEach(() => { client = createTestClient(); + originalRenderMessage = ModuleApi.instance.customComponents.renderMessage; + }); + + afterEach(() => { + ModuleApi.instance.customComponents.renderMessage = originalRenderMessage; + jest.restoreAllMocks(); }); it("rendering a tile defers to the module API", () => { @@ -258,4 +292,75 @@ describe("renderTile", () => { mxEvent: messageEvent, }); }); + + it("renders an incoming key verification request with the wrapped shared-components view", () => { + const sender = "@alice:example.com"; + const room = new Room(roomId, client, client.getSafeUserId()); + jest.spyOn(room, "getMember").mockImplementation((userId: string) => { + if (userId === sender) return { name: "Alice" } as RoomMember; + return null; + }); + mocked(client.getRoom).mockReturnValue(room); + + const verificationRequestEvent = makeVerificationRequestEvent({ + sender, + to: client.getUserId()!, + }); + + const tile = renderTile( + TimelineRenderingType.Room, + { mxEvent: verificationRequestEvent, showHiddenEvents: false }, + client, + ); + if (!tile) throw new Error("Expected a key verification request tile"); + + render(React.createElement(MatrixClientContext.Provider, { value: client }, tile)); + + expect(screen.getByText("Alice wants to verify")).toBeInTheDocument(); + expect(screen.getByText("Alice (@alice:example.com)")).toBeInTheDocument(); + }); + + it("renders an outgoing key verification request with the wrapped shared-components view", () => { + const recipient = "@alice:example.com"; + const room = new Room(roomId, client, client.getSafeUserId()); + jest.spyOn(room, "getMember").mockImplementation((userId: string) => { + if (userId === recipient) return { name: "Alice" } as RoomMember; + return null; + }); + mocked(client.getRoom).mockReturnValue(room); + + const verificationRequestEvent = makeVerificationRequestEvent({ + sender: client.getUserId()!, + to: recipient, + }); + + const tile = renderTile( + TimelineRenderingType.Room, + { mxEvent: verificationRequestEvent, showHiddenEvents: false }, + client, + ); + if (!tile) throw new Error("Expected a key verification request tile"); + + render(React.createElement(MatrixClientContext.Provider, { value: client }, tile)); + + expect(screen.getByText("You sent a verification request")).toBeInTheDocument(); + expect(screen.getByText("Alice (@alice:example.com)")).toBeInTheDocument(); + }); + + it("throws when a key verification request tile is rendered without a client context", () => { + jest.spyOn(console, "error").mockImplementation(() => {}); + const verificationRequestEvent = makeVerificationRequestEvent({ + sender: client.getUserId()!, + to: "@alice:example.com", + }); + + const tile = renderTile( + TimelineRenderingType.Room, + { mxEvent: verificationRequestEvent, showHiddenEvents: false }, + client, + ); + if (!tile) throw new Error("Expected a key verification request tile"); + + expect(() => render(tile)).toThrow("Attempting to render verification request without a client context!"); + }); }); diff --git a/apps/web/test/viewmodels/event-tiles/MKeyVerificationRequestViewModel-test.tsx b/apps/web/test/viewmodels/event-tiles/MKeyVerificationRequestViewModel-test.tsx new file mode 100644 index 0000000000..2afd7e4227 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/MKeyVerificationRequestViewModel-test.tsx @@ -0,0 +1,161 @@ +/* + * 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 from "react"; +import { type MatrixClient, MatrixEvent, type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; + +import { MKeyVerificationRequestViewModel } from "../../../src/viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel"; + +describe("MKeyVerificationRequestViewModel", () => { + const roomId = "!room:example.org"; + const myUserId = "@me:example.org"; + let cli: MatrixClient; + let room: Room; + + beforeEach(() => { + room = { + getMember: jest.fn(), + } as unknown as Room; + cli = { + getSafeUserId: jest.fn().mockReturnValue(myUserId), + getRoom: jest.fn().mockReturnValue(room), + } as unknown as MatrixClient; + }); + + const createEvent = (sender?: string, to = myUserId, eventRoomId: string | undefined = roomId): MatrixEvent => + new MatrixEvent({ + type: "m.room.message", + room_id: eventRoomId, + sender, + content: { + msgtype: "m.key.verification.request", + from_device: "DEVICE", + methods: ["m.sas.v1"], + to, + }, + }); + + it("throws if the event has no sender", () => { + expect(() => new MKeyVerificationRequestViewModel({ cli, mxEvent: createEvent() })).toThrow( + "Verification request did not include a sender!", + ); + }); + + it("throws if the event has no room", () => { + expect( + () => + new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org", myUserId, ""), + }), + ).toThrow("Verification request did not include a room ID!"); + }); + + it("renders a request sent by me", () => { + jest.mocked(room.getMember).mockReturnValue({ + name: "Alice", + } as RoomMember); + + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent(myUserId, "@alice:example.org"), + }); + + expect(vm.getSnapshot()).toMatchObject({ + title: "You sent a verification request", + subtitle: "Alice (@alice:example.org)", + }); + }); + + it("renders a request sent by someone else", () => { + jest.mocked(room.getMember).mockReturnValue({ + name: "Alice", + } as RoomMember); + + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + }); + + expect(vm.getSnapshot()).toMatchObject({ + title: "Alice wants to verify", + subtitle: "Alice (@alice:example.org)", + }); + }); + + it("falls back to user ID when no room member is available", () => { + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + }); + + expect(vm.getSnapshot()).toMatchObject({ + title: "@alice:example.org wants to verify", + subtitle: "@alice:example.org", + }); + }); + + it("updates the snapshot when the event changes", () => { + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(createEvent(myUserId, "@bob:example.org")); + + expect(vm.getSnapshot()).toMatchObject({ + title: "You sent a verification request", + subtitle: "@bob:example.org", + }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("updates only timestamp when the timestamp changes", () => { + const timestamp = 14:56; + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setTimestamp(timestamp); + + expect(vm.getSnapshot().timestamp).toBe(timestamp); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("does not emit when the event-derived snapshot is unchanged", () => { + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(createEvent("@alice:example.org")); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("does not emit when the timestamp is unchanged", () => { + const timestamp = 14:56; + const vm = new MKeyVerificationRequestViewModel({ + cli, + mxEvent: createEvent("@alice:example.org"), + timestamp, + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setTimestamp(timestamp); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/received-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/received-auto.png new file mode 100644 index 0000000000..fe9fbd0acd Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/received-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/sent-by-me-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/sent-by-me-auto.png new file mode 100644 index 0000000000..6bbd8eab9e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/sent-by-me-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/with-timestamp-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/with-timestamp-auto.png new file mode 100644 index 0000000000..75662a5a4e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx/with-timestamp-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 2ebeb4c7a9..23bc9d4758 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -40,6 +40,7 @@ export * from "./room/timeline/event-tile/actions/ActionBarView"; export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile"; export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView"; export * from "./room/timeline/event-tile/EventTileView/EventTileBubble"; +export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView"; export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge"; export * from "./room/timeline/event-tile/EventTileView/TextualEventView"; export * from "./room/timeline/event-tile/body/AudioPlayerView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.module.css new file mode 100644 index 0000000000..865b1c42a1 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.module.css @@ -0,0 +1,12 @@ +/* + * 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 { + svg { + color: var(--cpd-color-text-primary); + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx new file mode 100644 index 0000000000..8a181b3c6d --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.stories.tsx @@ -0,0 +1,60 @@ +/* + * 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 type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { MKeyVerificationRequestView, type MKeyVerificationRequestViewSnapshot } from "./MKeyVerificationRequestView"; + +type MKeyVerificationRequestViewProps = MKeyVerificationRequestViewSnapshot & { + className?: string; +}; + +const MKeyVerificationRequestViewWrapperImpl = ({ + className, + ...snapshot +}: MKeyVerificationRequestViewProps): JSX.Element => { + const vm = useMockedViewModel(snapshot, {}); + + return ; +}; + +const MKeyVerificationRequestViewWrapper = withViewDocs( + MKeyVerificationRequestViewWrapperImpl, + MKeyVerificationRequestView, +); + +const meta = { + title: "Timeline/Timeline Event/MKeyVerificationRequestView", + component: MKeyVerificationRequestViewWrapper, + tags: ["autodocs"], + args: { + title: "Alice wants to verify", + subtitle: "Alice (@alice:example.org)", + className: "", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Received: Story = {}; + +export const SentByMe: Story = { + args: { + title: "You sent a verification request", + subtitle: "Bob (@bob:example.org)", + }, +}; + +export const WithTimestamp: Story = { + args: { + timestamp: 14:56, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.test.tsx new file mode 100644 index 0000000000..c61a00b138 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.test.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 { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { MKeyVerificationRequestView } from "./MKeyVerificationRequestView"; +import * as stories from "./MKeyVerificationRequestView.stories"; + +const { Received, SentByMe, WithTimestamp } = composeStories(stories); + +describe("MKeyVerificationRequestView", () => { + it("renders the Received story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Alice wants to verify")).toBeInTheDocument(); + expect(screen.getByText("Alice (@alice:example.org)")).toBeInTheDocument(); + }); + + it("renders the SentByMe story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("You sent a verification request")).toBeInTheDocument(); + expect(screen.getByText("Bob (@bob:example.org)")).toBeInTheDocument(); + }); + + it("renders a timestamp", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("14:56")).toBeInTheDocument(); + }); + + it("applies a custom className to the root element", () => { + const vm = new MockViewModel({ + title: "Alice wants to verify", + subtitle: "Alice (@alice:example.org)", + }); + const { container } = render(); + + expect(container.firstChild).toHaveClass("custom-verification"); + }); + + it("forwards the provided ref to the root element", () => { + const ref = React.createRef() as React.RefObject; + const vm = new MockViewModel({ + title: "Alice wants to verify", + subtitle: "Alice (@alice:example.org)", + }); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveTextContent("Alice wants to verify"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.tsx new file mode 100644 index 0000000000..6814883e91 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/MKeyVerificationRequestView.tsx @@ -0,0 +1,69 @@ +/* + * 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 } from "react"; +import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import { EventTileBubble } from "../EventTileBubble"; +import styles from "./MKeyVerificationRequestView.module.css"; + +export interface MKeyVerificationRequestViewSnapshot { + /** + * Main title text for the verification request. + */ + title: string; + /** + * Label for the other user involved in the request. + */ + subtitle: string; + /** + * Optional timestamp element rendered in the EventTileBubble footer slot. + */ + timestamp?: JSX.Element; +} + +export type MKeyVerificationRequestViewModel = ViewModel; + +export interface MKeyVerificationRequestViewProps { + /** + * ViewModel providing the current verification request snapshot. + */ + vm: MKeyVerificationRequestViewModel; + /** + * Optional CSS classes passed through to EventTileBubble. + */ + className?: string; + /** + * Optional Ref forwarded to the root DOM element. + */ + ref?: React.RefObject; +} + +/** + * Renders a timeline bubble describing a key verification request message. + */ +export function MKeyVerificationRequestView({ + vm, + className, + ref, +}: Readonly): JSX.Element { + const { title, subtitle, timestamp } = useViewModel(vm); + + return ( + } + className={classNames(styles.content, className)} + title={title} + subtitle={subtitle} + ref={ref} + > + {timestamp} + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/__snapshots__/MKeyVerificationRequestView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/__snapshots__/MKeyVerificationRequestView.test.tsx.snap new file mode 100644 index 0000000000..2ca016919d --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/__snapshots__/MKeyVerificationRequestView.test.tsx.snap @@ -0,0 +1,94 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MKeyVerificationRequestView > renders a timestamp 1`] = ` +
+
+ + + +
+ Alice wants to verify +
+
+ Alice (@alice:example.org) +
+ + 14:56 + +
+
+`; + +exports[`MKeyVerificationRequestView > renders the Received story 1`] = ` +
+
+ + + +
+ Alice wants to verify +
+
+ Alice (@alice:example.org) +
+
+
+`; + +exports[`MKeyVerificationRequestView > renders the SentByMe story 1`] = ` +
+
+ + + +
+ You sent a verification request +
+
+ Bob (@bob:example.org) +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/index.ts b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/index.ts new file mode 100644 index 0000000000..641da39c23 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MKeyVerificationRequestView/index.ts @@ -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 { + MKeyVerificationRequestView, + type MKeyVerificationRequestViewProps, + type MKeyVerificationRequestViewSnapshot, + type MKeyVerificationRequestViewModel, +} from "./MKeyVerificationRequestView";