diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index 994cd4a101..8c4888bff5 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -718,7 +718,7 @@ test.describe("Timeline", () => { await viewSourceEventExpanded.hover(); const toggleEventButton = viewSourceEventExpanded.getByRole("button", { name: "toggle event" }); // Check size and position of toggle on expanded view source event - // See: _ViewSourceEvent.pcss + // See: ViewSourceEventView.module.css await expect(toggleEventButton).toHaveCSS("height", "16px"); // --ViewSourceEvent_toggle-size await expect(toggleEventButton).toHaveCSS("align-self", "flex-end"); // Click again to collapse the source diff --git a/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png b/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png index 85ef504581..4ae55b69f0 100644 Binary files a/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png and b/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-bubble-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png b/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png index 669ef94f3c..09448c389d 100644 Binary files a/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png and b/apps/web/playwright/snapshots/threads/threads.spec.ts/ThreadView-with-reaction-and-a-hidden-event-on-group-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png index c439f47c80..ebe765cc4e 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-padding-modern-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png index 8383107083..bf70a9c99b 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hidden-event-line-zero-padding-irc-layout-linux.png differ diff --git a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png index 247c8313ff..e2ba14049d 100644 Binary files a/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png and b/apps/web/playwright/snapshots/timeline/timeline.spec.ts/hovered-hidden-event-line-irc-layout-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index fc2f1be5ea..a215241bbf 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -234,7 +234,6 @@ @import "./views/messages/_ReactionsRow.pcss"; @import "./views/messages/_TextualEvent.pcss"; @import "./views/messages/_ThreadActionBar.pcss"; -@import "./views/messages/_ViewSourceEvent.pcss"; @import "./views/messages/_common_CryptoEvent.pcss"; @import "./views/polls/pollHistory/_PollHistory.pcss"; @import "./views/polls/pollHistory/_PollHistoryList.pcss"; diff --git a/apps/web/res/css/views/messages/_ViewSourceEvent.pcss b/apps/web/res/css/views/messages/_ViewSourceEvent.pcss deleted file mode 100644 index 02dce05bbb..0000000000 --- a/apps/web/res/css/views/messages/_ViewSourceEvent.pcss +++ /dev/null @@ -1,50 +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_EventTile_content.mx_ViewSourceEvent { - display: flex; - opacity: 0.6; - font-size: $font-12px; - width: 100%; - overflow-x: auto; /* Cancel overflow setting of .mx_EventTile_content */ - line-height: normal; /* Align with avatar and E2E icon */ - - pre, - code { - flex: 1; - } - - pre { - line-height: 1.2; - margin: 3.5px 0; - } - - .mx_ViewSourceEvent_toggle { - --ViewSourceEvent_toggle-size: 16px; - - visibility: hidden; - /* icon */ - width: var(--ViewSourceEvent_toggle-size); - min-width: var(--ViewSourceEvent_toggle-size); - - svg { - color: $accent; - width: var(--ViewSourceEvent_toggle-size); - height: var(--ViewSourceEvent_toggle-size); - } - - .mx_EventTile:hover & { - visibility: visible; - } - } - - &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { - align-self: flex-end; - height: var(--ViewSourceEvent_toggle-size); - } -} diff --git a/apps/web/src/components/views/messages/ViewSourceEvent.tsx b/apps/web/src/components/views/messages/ViewSourceEvent.tsx deleted file mode 100644 index e7ed1f8332..0000000000 --- a/apps/web/src/components/views/messages/ViewSourceEvent.tsx +++ /dev/null @@ -1,83 +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 { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; -import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; - -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { _t } from "../../../languageHandler"; -import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; - -interface IProps { - mxEvent: MatrixEvent; -} - -interface IState { - expanded: boolean; -} - -export default class ViewSourceEvent extends React.PureComponent { - public constructor(props: IProps) { - super(props); - - this.state = { - expanded: false, - }; - } - - public componentDidMount(): void { - const { mxEvent } = this.props; - - const client = MatrixClientPeg.safeGet(); - client.decryptEventIfNeeded(mxEvent); - - if (mxEvent.isBeingDecrypted()) { - mxEvent.once(MatrixEventEvent.Decrypted, () => this.forceUpdate()); - } - } - - private onToggle = (ev: ButtonEvent): void => { - ev.preventDefault(); - const { expanded } = this.state; - this.setState({ - expanded: !expanded, - }); - }; - - public render(): React.ReactNode { - const { mxEvent } = this.props; - const { expanded } = this.state; - - let content; - if (expanded) { - content =
{JSON.stringify(mxEvent, null, 4)}
; - } else { - content = {`{ "type": ${mxEvent.getType()} }`}; - } - - const classes = classNames("mx_ViewSourceEvent mx_EventTile_content", { - mx_ViewSourceEvent_expanded: expanded, - }); - - return ( - - {content} - - {expanded ? : } - - - ); - } -} diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index e707ee9d61..830f427614 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -25,6 +25,7 @@ import { MKeyVerificationRequestView, RoomAvatarEventView, TextualEventView, + ViewSourceEventView, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; @@ -44,7 +45,6 @@ import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { WidgetType } from "../widgets/WidgetType"; import { hasText } from "../TextForEvent"; import { getMessageModerationState, MessageModerationState } from "../utils/EventUtils"; -import ViewSourceEvent from "../components/views/messages/ViewSourceEvent"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { type IBodyProps } from "../components/views/messages/IBodyProps"; import { ModuleApi } from "../modules/Api"; @@ -54,6 +54,7 @@ import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/ev import { RoomAvatarEventViewModel } from "../viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel"; import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel"; import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; +import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel"; import { ElementCallEventType } from "../call-types"; import { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel"; @@ -127,6 +128,24 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { } const HiddenEventFactory: Factory = (ref, props) => ; +function ViewSourceEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel(() => new ViewSourceEventViewModel({ mxEvent, cli })); + + useEffect(() => { + vm.setProps({ cli, mxEvent }); + }, [cli, mxEvent, vm]); + + return ( + + ); +} + function MJitsiWidgetEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { const cli = useMatrixClientContext(); const vm = useCreateAutoDisposedViewModel(() => new MJitsiWidgetEventViewModel({ mxEvent, cli })); @@ -178,8 +197,8 @@ export const CallStartedEventFactory: Factory = (ref, props) => { }; // These factories are exported for reference comparison against pickFactory() +export const JSONEventFactory: Factory = (ref, props) => ; export const JitsiEventFactory: Factory = (ref, props) => ; -export const JSONEventFactory: Factory = (ref, props) => ; export const RoomCreateEventFactory: Factory = (_ref, props) => ; const EVENT_TILE_TYPES = new Map([ diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index f402a1760f..bc52ce33b9 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -892,7 +892,6 @@ "thread_root_id": "Thread Root ID: %(threadRootId)s", "threads_timeline": "Threads timeline", "title": "Developer tools", - "toggle_event": "toggle event", "toolbox": "Toolbox", "use_at_own_risk": "This UI does NOT check the types of the values. Use at your own risk.", "user_avatar": "Avatar: %(avatar)s", diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel.ts new file mode 100644 index 0000000000..d574ee1572 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel.ts @@ -0,0 +1,113 @@ +/* +Copyright 2026 Element Creations Ltd. +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 { type MouseEvent } from "react"; +import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + Disposables, + type ViewSourceEventViewModel as ViewSourceEventViewModelInterface, + type ViewSourceEventViewSnapshot, +} from "@element-hq/web-shared-components"; + +export interface ViewSourceEventViewModelProps { + /** + * The hidden event whose source is being rendered. + */ + mxEvent: MatrixEvent; + /** + * Matrix client used to request decryption before rendering event source. + */ + cli: MatrixClient; +} + +/** + * ViewModel for hidden event source rendering. + */ +export class ViewSourceEventViewModel + extends BaseViewModel + implements ViewSourceEventViewModelInterface +{ + private decryptionListenerDisposables?: Disposables; + + private static computeSnapshot( + { mxEvent }: ViewSourceEventViewModelProps, + expanded: boolean, + ): ViewSourceEventViewSnapshot { + return { + expanded, + preview: `{ "type": ${mxEvent.getType()} }`, + source: expanded ? ViewSourceEventViewModel.computeSource(mxEvent) : "", + }; + } + + private static computeSource(mxEvent: MatrixEvent): string { + return JSON.stringify(mxEvent, null, 4) ?? ""; + } + + public constructor(props: ViewSourceEventViewModelProps) { + super(props, ViewSourceEventViewModel.computeSnapshot(props, false)); + this.disposables.track(() => this.removeDecryptionListener()); + this.setupDecryptionListener(); + } + + public setProps(newProps: Partial): void { + const nextProps = { ...this.props, ...newProps }; + const eventChanged = this.props.mxEvent !== nextProps.mxEvent; + const clientChanged = this.props.cli !== nextProps.cli; + + if (!eventChanged && !clientChanged) return; + + this.props = nextProps; + + this.setupDecryptionListener(); + + if (eventChanged) { + this.updateSnapshotFromProps(); + } + } + + public onToggle = (event: MouseEvent): void => { + event.preventDefault(); + + const expanded = !this.snapshot.current.expanded; + this.snapshot.merge({ + expanded, + source: expanded ? ViewSourceEventViewModel.computeSource(this.props.mxEvent) : "", + }); + }; + + private updateSnapshotFromProps(): void { + this.snapshot.merge(ViewSourceEventViewModel.computeSnapshot(this.props, this.snapshot.current.expanded)); + } + + private setupDecryptionListener(): void { + this.removeDecryptionListener(); + + const { cli, mxEvent } = this.props; + cli.decryptEventIfNeeded(mxEvent); + + if (!mxEvent.isBeingDecrypted()) return; + + const onDecrypted = (): void => { + this.removeDecryptionListener(); + if (this.props.mxEvent !== mxEvent) return; + + this.updateSnapshotFromProps(); + }; + + this.decryptionListenerDisposables = new Disposables(); + this.decryptionListenerDisposables.trackListener(mxEvent, MatrixEventEvent.Decrypted, onDecrypted); + } + + private removeDecryptionListener(): void { + this.decryptionListenerDisposables?.dispose(); + this.decryptionListenerDisposables = undefined; + } +} diff --git a/apps/web/test/viewmodels/message-body/ViewSourceEventViewModel-test.ts b/apps/web/test/viewmodels/message-body/ViewSourceEventViewModel-test.ts new file mode 100644 index 0000000000..6b0ee8e6f6 --- /dev/null +++ b/apps/web/test/viewmodels/message-body/ViewSourceEventViewModel-test.ts @@ -0,0 +1,143 @@ +/* +Copyright 2026 Element Creations Ltd. +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 { type MouseEvent } from "react"; +import { type MatrixClient, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; + +import { ViewSourceEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel"; + +describe("ViewSourceEventViewModel", () => { + const createClient = (): MatrixClient => + ({ + decryptEventIfNeeded: jest.fn().mockResolvedValue(undefined), + }) as unknown as MatrixClient; + + const createEvent = (type = "m.room.message", content: Record = {}): MatrixEvent => + new MatrixEvent({ + type, + event_id: "$event:example.org", + sender: "@alice:example.org", + content, + }); + + const createClickEvent = (): MouseEvent => + ({ + preventDefault: jest.fn(), + }) as unknown as MouseEvent; + + it("creates a collapsed event source snapshot and requests decryption", () => { + const cli = createClient(); + const mxEvent = createEvent("m.room.member"); + const vm = new ViewSourceEventViewModel({ cli, mxEvent }); + + expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent); + expect(vm.getSnapshot()).toEqual({ + expanded: false, + preview: '{ "type": m.room.member }', + source: "", + }); + }); + + it("toggles expanded state", () => { + const mxEvent = createEvent(); + const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent }); + const event = createClickEvent(); + + vm.onToggle(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(vm.getSnapshot().expanded).toBe(true); + expect(vm.getSnapshot().source).toBe(JSON.stringify(mxEvent, null, 4)); + + vm.onToggle(createClickEvent()); + + expect(vm.getSnapshot().expanded).toBe(false); + expect(vm.getSnapshot().source).toBe(""); + }); + + it("updates the event source when the event changes", () => { + const cli = createClient(); + const oldEvent = createEvent("m.room.message"); + const newEvent = createEvent("m.room.topic", { topic: "New topic" }); + const vm = new ViewSourceEventViewModel({ cli, mxEvent: oldEvent }); + + vm.onToggle(createClickEvent()); + vm.setProps({ mxEvent: newEvent }); + + expect(cli.decryptEventIfNeeded).toHaveBeenCalledWith(newEvent); + expect(vm.getSnapshot()).toEqual({ + expanded: true, + preview: '{ "type": m.room.topic }', + source: JSON.stringify(newEvent, null, 4), + }); + }); + + it("removes the previous decryption listener when the event changes", () => { + const oldEvent = createEvent("m.room.encrypted"); + jest.spyOn(oldEvent, "isBeingDecrypted").mockReturnValue(true); + const offSpy = jest.spyOn(oldEvent, "off"); + const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent: oldEvent }); + + vm.setProps({ mxEvent: createEvent("m.room.message") }); + + expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + }); + + it("updates the decryption request when the client changes", () => { + const oldClient = createClient(); + const newClient = createClient(); + const mxEvent = createEvent(); + const vm = new ViewSourceEventViewModel({ cli: oldClient, mxEvent }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setProps({ cli: newClient }); + + expect(newClient.decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent); + expect(listener).not.toHaveBeenCalled(); + }); + + it("does not emit when setProps receives unchanged props", () => { + const cli = createClient(); + const mxEvent = createEvent(); + const vm = new ViewSourceEventViewModel({ cli, mxEvent }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setProps({ cli, mxEvent }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("updates the source after decryption completes", () => { + const mxEvent = createEvent("m.room.encrypted", { ciphertext: "encrypted" }); + jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true); + const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent }); + vm.onToggle(createClickEvent()); + const listener = jest.fn(); + vm.subscribe(listener); + + mxEvent.getContent().body = "decrypted"; + mxEvent.emit(MatrixEventEvent.Decrypted, mxEvent); + + expect(listener).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot().source).toContain("decrypted"); + }); + + it("removes decryption listeners on dispose", () => { + const mxEvent = createEvent("m.room.encrypted"); + jest.spyOn(mxEvent, "isBeingDecrypted").mockReturnValue(true); + const offSpy = jest.spyOn(mxEvent, "off"); + const vm = new ViewSourceEventViewModel({ cli: createClient(), mxEvent }); + + vm.dispose(); + + expect(offSpy).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..6264a92573 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/expanded-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/expanded-auto.png new file mode 100644 index 0000000000..965add6cf5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx/expanded-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 70b1193704..83fa213d22 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -41,6 +41,9 @@ "preferences": "Preferences", "state_encryption_enabled": "Experimental state encryption enabled" }, + "devtools": { + "toggle_event": "toggle event" + }, "keyboard": { "shift": "Shift" }, diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index c81e1943d1..a7a06ec999 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -27,6 +27,7 @@ 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"; +export * from "./room/timeline/event-tile/body/ViewSourceEventView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; export * from "./core/pill-input/Pill"; export * from "./core/pill-input/PillInput"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.module.css new file mode 100644 index 0000000000..abaf8b6144 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.module.css @@ -0,0 +1,64 @@ +/* +Copyright 2026 Element Creations Ltd. +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. +*/ + +.content { + display: flex; + color: var(--cpd-color-text-secondary); + font-size: var(--cpd-font-size-body-xs); + width: 100%; + overflow-x: auto; + line-height: normal; +} + +.source { + flex: 1; +} + +pre.source { + line-height: 1.2; + margin: 3.5px 0; +} + +.toggle { + --ViewSourceEvent_toggle-size: 16px; + + appearance: none; + border: 0; + padding: 0; + background: none; + color: var(--cpd-color-icon-accent-primary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + visibility: hidden; + width: var(--ViewSourceEvent_toggle-size); + min-width: var(--ViewSourceEvent_toggle-size); + height: var(--ViewSourceEvent_toggle-size); +} + +.content:hover .toggle, +.toggle:focus-visible { + visibility: visible; +} + +.toggle:focus-visible { + outline: 2px solid var(--cpd-color-border-focused); + outline-offset: 2px; + border-radius: var(--cpd-space-1x); +} + +.toggle svg { + width: var(--ViewSourceEvent_toggle-size); + height: var(--ViewSourceEvent_toggle-size); +} + +.expanded .toggle { + align-self: flex-end; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx new file mode 100644 index 0000000000..2e248660d3 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.stories.tsx @@ -0,0 +1,77 @@ +/* +Copyright 2026 Element Creations Ltd. +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, { 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 { + ViewSourceEventView, + type ViewSourceEventViewActions, + type ViewSourceEventViewSnapshot, +} from "./ViewSourceEventView"; + +type ViewSourceEventViewProps = ViewSourceEventViewSnapshot & + ViewSourceEventViewActions & { + className?: string; + expandedClassName?: string; + }; + +const source = JSON.stringify( + { + type: "m.room.message", + sender: "@alice:example.org", + content: { + msgtype: "m.text", + body: "Hello", + }, + }, + null, + 4, +); + +const ViewSourceEventViewWrapperImpl = ({ + onToggle, + className, + expandedClassName, + ...snapshot +}: ViewSourceEventViewProps): JSX.Element => { + const vm = useMockedViewModel(snapshot, { onToggle }); + + return ; +}; + +const ViewSourceEventViewWrapper = withViewDocs(ViewSourceEventViewWrapperImpl, ViewSourceEventView); + +const meta = { + title: "Timeline/Timeline Event/ViewSourceEventView", + component: ViewSourceEventViewWrapper, + tags: ["autodocs"], + args: { + expanded: false, + preview: '{ "type": m.room.message }', + source, + onToggle: fn(), + className: "", + expandedClassName: "", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Expanded: Story = { + args: { + expanded: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.test.tsx new file mode 100644 index 0000000000..0b75718aa5 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.test.tsx @@ -0,0 +1,107 @@ +/* +Copyright 2026 Element Creations Ltd. +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 { composeStories } from "@storybook/react-vite"; +import { fireEvent, render, screen } from "@test-utils"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { + ViewSourceEventView, + type ViewSourceEventViewActions, + type ViewSourceEventViewModel, + type ViewSourceEventViewSnapshot, +} from "./ViewSourceEventView"; +import * as stories from "./ViewSourceEventView.stories"; + +const { Default, Expanded } = composeStories(stories); + +class TestViewSourceEventViewModel + extends MockViewModel + implements ViewSourceEventViewActions +{ + public constructor( + snapshot: ViewSourceEventViewSnapshot, + public onToggle: ViewSourceEventViewActions["onToggle"], + ) { + super(snapshot); + } +} + +const createVm = ( + snapshot: Partial = {}, + onToggle: ViewSourceEventViewActions["onToggle"] = vi.fn(), +): ViewSourceEventViewModel => + new TestViewSourceEventViewModel( + { + expanded: false, + preview: '{ "type": m.room.message }', + source: '{\n "type": "m.room.message"\n}', + ...snapshot, + }, + onToggle, + ) as ViewSourceEventViewModel; + +describe("ViewSourceEventView", () => { + const getToggleButton = (container: HTMLElement): HTMLButtonElement => { + const button = container.querySelector('button[aria-label="toggle event"]'); + + if (!button) { + throw new Error("Expected view source toggle button to be rendered"); + } + + return button; + }; + + it("renders the default story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText('{ "type": m.room.message }')).toBeInTheDocument(); + expect(getToggleButton(container)).toBeInTheDocument(); + }); + + it("renders the expanded story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText(/"sender": "@alice:example\.org"/)).toBeInTheDocument(); + }); + + it("invokes the toggle action", () => { + const onToggle = vi.fn(); + const vm = createVm({}, onToggle); + + const { container } = render(); + + fireEvent.click(getToggleButton(container)); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + it("applies custom class names to the root element", () => { + const vm = createVm({ expanded: true }); + + const { container } = render( + , + ); + + expect(container.firstChild).toHaveClass("custom-source", "custom-expanded"); + }); + + it("forwards the provided ref to the root span", () => { + const ref = React.createRef(); + const vm = createVm(); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLSpanElement); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.tsx new file mode 100644 index 0000000000..b0809be9d5 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/ViewSourceEventView.tsx @@ -0,0 +1,98 @@ +/* +Copyright 2026 Element Creations Ltd. +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, { type JSX, type MouseEventHandler, type Ref } from "react"; +import classNames from "classnames"; +import { CollapseIcon, ExpandIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Tooltip } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import { useI18n } from "../../../../../core/i18n/i18nContext"; +import styles from "./ViewSourceEventView.module.css"; + +export interface ViewSourceEventViewSnapshot { + /** + * Whether the full event source is visible. + */ + expanded: boolean; + /** + * Collapsed one-line event summary. + */ + preview: string; + /** + * Pretty-printed event source. + */ + source: string; +} + +export interface ViewSourceEventViewActions { + /** + * Invoked when the user expands or collapses the event source. + */ + onToggle: MouseEventHandler; +} + +export type ViewSourceEventViewModel = ViewModel; + +interface ViewSourceEventViewProps { + /** + * ViewModel providing the event source snapshot and actions. + */ + vm: ViewSourceEventViewModel; + /** + * Optional CSS class names applied to the root element. + */ + className?: string; + /** + * Optional CSS class name applied to the root element while expanded. + */ + expandedClassName?: string; + /** + * Optional ref forwarded to the root element. + */ + ref?: Ref; +} + +/** + * Renders a collapsible event source preview for hidden timeline events. + */ +export function ViewSourceEventView({ + vm, + className, + expandedClassName, + ref, +}: Readonly): JSX.Element { + const { expanded, preview, source } = useViewModel(vm); + const _t = useI18n().translate; + const toggleLabel = _t("devtools|toggle_event"); + + const classes = classNames( + styles.content, + className, + { + [styles.expanded]: expanded, + }, + expanded && expandedClassName, + ); + + return ( + + {expanded ? ( +
{source}
+ ) : ( + {preview} + )} + + + +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/__snapshots__/ViewSourceEventView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/__snapshots__/ViewSourceEventView.test.tsx.snap new file mode 100644 index 0000000000..79703e7c36 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/__snapshots__/ViewSourceEventView.test.tsx.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ViewSourceEventView > renders the default story 1`] = ` +
+ + + { "type": m.room.message } + + + +
+`; + +exports[`ViewSourceEventView > renders the expanded story 1`] = ` +
+ +
+      {
+    "type": "m.room.message",
+    "sender": "@alice:example.org",
+    "content": {
+        "msgtype": "m.text",
+        "body": "Hello"
+    }
+}
+    
+ +
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/index.tsx new file mode 100644 index 0000000000..6c642b4d93 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/ViewSourceEventView/index.tsx @@ -0,0 +1,10 @@ +/* +Copyright 2026 Element Creations Ltd. +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. +*/ + +export * from "./ViewSourceEventView";