diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c53af59eb..2c8720d408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +Changes in [1.12.18](https://github.com/element-hq/element-web/releases/tag/v1.12.18) (2026-05-12) +================================================================================================== +## ✨ Features + +* Room list: add collapse/expand all sections ([#33318](https://github.com/element-hq/element-web/pull/33318)). Contributed by @florianduros. +* Show user status in timeline ([#32991](https://github.com/element-hq/element-web/pull/32991)). Contributed by @Half-Shot. +* Disable URL Preview setting if disabled on the homeserver ([#33279](https://github.com/element-hq/element-web/pull/33279)). Contributed by @Half-Shot. +* Go to welcome on logout ([#33306](https://github.com/element-hq/element-web/pull/33306)). Contributed by @t3chguy. +* Room list: edit or remove custom sections ([#33283](https://github.com/element-hq/element-web/pull/33283)). Contributed by @florianduros. +* Re-generate QR code if the channel expires before scan ([#33303](https://github.com/element-hq/element-web/pull/33303)). Contributed by @t3chguy. +* Update toast styles, improve incoming call notifications ([#33043](https://github.com/element-hq/element-web/pull/33043)). Contributed by @robintown. +* Add Module Composer API ([#33284](https://github.com/element-hq/element-web/pull/33284)). Contributed by @Half-Shot. +* Room list: exclude default section from room list item menu ([#33278](https://github.com/element-hq/element-web/pull/33278)). Contributed by @florianduros. +* Show 'Verify this device' toast even if there are no encrypted rooms yet ([#32891](https://github.com/element-hq/element-web/pull/32891)). Contributed by @andybalaam. +* Promote "Share encrypted history" from labs ([#33281](https://github.com/element-hq/element-web/pull/33281)). Contributed by @richvdh. +* Room list: assign room to section when section is created ([#33240](https://github.com/element-hq/element-web/pull/33240)). Contributed by @florianduros. +* Confirm before inviting unknown users to a DM/room ([#33171](https://github.com/element-hq/element-web/pull/33171)). Contributed by @richvdh. +* Room list: assign room to custom section ([#33238](https://github.com/element-hq/element-web/pull/33238)). Contributed by @florianduros. +* Redesign link previews ([#33061](https://github.com/element-hq/element-web/pull/33061)). Contributed by @Half-Shot. +* Room list: scroll to newly creation section ([#33210](https://github.com/element-hq/element-web/pull/33210)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Update home page CSS ([#32723](https://github.com/element-hq/element-web/pull/32723)). Contributed by @wolterkam. +* Web: Fix typo in `152x152` icon source of `manifest.json` ([#33369](https://github.com/element-hq/element-web/pull/33369)). Contributed by @bartvdbraak. +* prevent replay hover from restarting playback ([#33364](https://github.com/element-hq/element-web/pull/33364)). Contributed by @ZacksBot. +* Properly save `undefined` id tokens from OIDC login ([#33345](https://github.com/element-hq/element-web/pull/33345)). Contributed by @gingershaped. +* Show the right cursor when hovering over a space ([#33351](https://github.com/element-hq/element-web/pull/33351)). Contributed by @robintown. +* Set `type` in auth dict for `m.oauth` UIA stage ([#33344](https://github.com/element-hq/element-web/pull/33344)). Contributed by @gingershaped. +* Remove duplicated UI in appearance settings ([#33336](https://github.com/element-hq/element-web/pull/33336)). Contributed by @t3chguy. +* Move playwright-common wait-on from devDependencies to dependencies ([#33272](https://github.com/element-hq/element-web/pull/33272)). Contributed by @t3chguy. + + Changes in [1.12.17](https://github.com/element-hq/element-web/releases/tag/v1.12.17) (2026-04-30) ================================================================================================== ## 🐛 Bug Fixes diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81f93a4947..8dcbbcdd12 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "productName": "Element", "main": "lib/electron-main.js", "exports": "./lib/electron-main.js", - "version": "1.12.17", + "version": "1.12.18", "description": "Element: the future of secure communication", "author": { "name": "Element", diff --git a/apps/web/package.json b/apps/web/package.json index 93c57ef6af..2a094d9ae6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.17", + "version": "1.12.18", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index 994cd4a101..a9a25bcbb4 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 @@ -751,7 +751,7 @@ test.describe("Timeline", () => { await expect(page.locator(".mx_EventTile[data-layout=irc] .mx_ViewSourceEvent_expanded")).toBeVisible(); }); - test("should render file size in kibibytes on a file tile", async ({ page, room }) => { + test("should render file size in kibibytes on a file tile", async ({ page, app, room }) => { await page.goto(`/#/room/${room.roomId}`); await expect( page @@ -760,12 +760,7 @@ test.describe("Timeline", () => { ).toBeVisible(); // Upload a file from the message composer - await page - .locator(".mx_MessageComposer_actions input[type='file']") - .setInputFiles(getSampleFilePath("matrix-org-client-versions.json")); - - // Click "Upload" button - await page.locator(".mx_Dialog").getByRole("button", { name: "Upload" }).click(); + await app.composerUploadFiles("room", getSampleFilePath("matrix-org-client-versions.json")); // Wait until the file is sent await expect(page.locator(".mx_RoomView_statusArea_expanded")).not.toBeVisible(); diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index 41abc8f980..f933484ff0 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -169,8 +169,7 @@ export class ElementAppPage { ): ReturnType { const input = this.page .locator(location === "room" ? ".mx_RoomView_body" : ".mx_RightPanel") - .getByRole("region", { name: "Message composer" }) - .locator("input[type='file']"); + .getByTestId("room-upload-context-input"); return input.setInputFiles(...params); } 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/ContentMessages.ts b/apps/web/src/ContentMessages.ts index ec68f854fe..15a43d79b6 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -607,7 +607,7 @@ export default class ContentMessages { throw e; } // Otherwise we failed to thumbnail, fall back to uploading an m.file - logger.error(e); + logger.error(`Expected file of type "${file.type}" to be an image, but got`, e); content.msgtype = MsgType.File; } } else if (file.type.startsWith("audio/")) { diff --git a/apps/web/src/components/structures/FileDropTarget.tsx b/apps/web/src/components/structures/FileDropTarget.tsx index 564383d9f4..a81c05fbad 100644 --- a/apps/web/src/components/structures/FileDropTarget.tsx +++ b/apps/web/src/components/structures/FileDropTarget.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -7,16 +8,14 @@ Please see LICENSE files in the repository root for full details. */ import React, { useEffect, useState } from "react"; -import { type Room } from "matrix-js-sdk/src/matrix"; +import { useViewModel } from "@element-hq/web-shared-components"; import { _t } from "../../languageHandler"; import UploadBigSvg from "../../../res/img/upload-big.svg"; -import { useRoomState } from "../../hooks/useRoomState.ts"; +import { useRoomUploadViewModel } from "../../viewmodels/room/RoomUploadViewModel"; interface IProps { - room: Room; parent: HTMLElement | null; - onFileDrop(this: void, dataTransfer: DataTransfer): void; } interface IState { @@ -24,15 +23,16 @@ interface IState { counter: number; } -const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { +const FileDropTarget: React.FC = ({ parent }) => { const [state, setState] = useState({ dragging: false, counter: 0, }); - const hasPermission = useRoomState(room, (state) => state.maySendMessage(room.client.getUserId()!)); + const vm = useRoomUploadViewModel(); + const { mayUpload } = useViewModel(vm); useEffect(() => { - if (!hasPermission || !parent || parent.ondrop) return; + if (!mayUpload || !parent || parent.ondrop) return; const onDragEnter = (ev: DragEvent): void => { ev.stopPropagation(); @@ -83,7 +83,7 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { ev.stopPropagation(); ev.preventDefault(); if (!ev.dataTransfer) return; - onFileDrop(ev.dataTransfer); + void vm.initiateViaDataTransfer(ev.dataTransfer); setState((state) => ({ dragging: false, @@ -106,9 +106,9 @@ const FileDropTarget: React.FC = ({ parent, onFileDrop, room }) => { parent?.removeEventListener("dragenter", onDragEnter); parent?.removeEventListener("dragleave", onDragLeave); }; - }, [parent, onFileDrop, hasPermission]); + }, [parent, mayUpload, vm]); - if (hasPermission && state.dragging) { + if (mayUpload && state.dragging) { return (
diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index b5c03938bc..9483b05a9c 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -142,6 +142,7 @@ import { type RoomViewStore } from "../../stores/RoomViewStore.tsx"; import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts"; import { EncryptionEventViewModel } from "../../viewmodels/room/timeline/event-tile/EncryptionEventViewModel.ts"; import { ModuleApi } from "../../modules/Api.ts"; +import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; const DEBUG = false; @@ -302,7 +303,6 @@ interface LocalRoomViewProps { resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; roomView: RefObject; - onFileDrop: (dataTransfer: DataTransfer) => Promise; mainSplitContentType: MainSplitContentType; e2eStatus?: E2EStatus; } @@ -343,17 +343,19 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
-
- -
- - {encryptionTile} - - -
- {statusBar} - {composer} -
+ +
+ +
+ + {encryptionTile} + + +
+ {statusBar} + {composer} +
+
); @@ -2121,19 +2123,6 @@ export class RoomView extends React.Component { }); } - private onFileDrop = async (dataTransfer: DataTransfer): Promise => { - const roomId = this.getRoomId(); - if (!roomId || !this.context.client) return; - await ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - roomId, - undefined, - this.state.replyToEvent, - this.context.client, - TimelineRenderingType.Room, - ); - }; - private onMeasurement = (narrow: boolean): void => { this.setState({ narrow }); }; @@ -2169,7 +2158,6 @@ export class RoomView extends React.Component { resizeNotifier={this.context.resizeNotifier} permalinkCreator={this.permalinkCreator} roomView={this.roomView} - onFileDrop={this.onFileDrop} mainSplitContentType={this.state.mainSplitContentType} /> @@ -2673,16 +2661,12 @@ export class RoomView extends React.Component { case MainSplitContentType.Timeline: mainSplitContentClassName = "mx_MainSplit_timeline"; mainSplitBody = ( - <> + {auxPanel} {pinnedMessageBanner}
- + {topUnreadMessagesBar} {jumpToBottom} {messagePanel} @@ -2691,7 +2675,7 @@ export class RoomView extends React.Component { {statusBarArea} {previewBar} {messageComposer} - + ); break; case MainSplitContentType.MaximisedWidget: diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 05d639f5ce..6c0a04bb4b 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -29,7 +29,6 @@ import TimelinePanel from "./TimelinePanel"; import dis from "../../dispatcher/dispatcher"; import { type ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; import { type E2EStatus } from "../../utils/ShieldUtils"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; @@ -51,6 +50,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo import Heading from "../views/typography/Heading"; import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload"; import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../utils/EventPresentationContextProvider"; interface IProps { @@ -329,22 +329,6 @@ export default class ThreadView extends React.Component { } }; - private onFileDrop = (dataTransfer: DataTransfer): void => { - const roomId = this.props.mxEvent.getRoomId(); - if (roomId) { - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(dataTransfer.files), - roomId, - this.threadRelation, - this.context.replyToEvent, - MatrixClientPeg.safeGet(), - TimelineRenderingType.Thread, - ); - } else { - console.warn("Unknwon roomId for event", this.props.mxEvent); - } - }; - private get threadRelation(): IEventRelation { const relation: IEventRelation = { rel_type: THREAD_RELATION_TYPE.name, @@ -393,7 +377,7 @@ export default class ThreadView extends React.Component { timeline = ( <> - + { liveTimeline={this.state?.thread?.timelineSet?.getLiveTimeline()} narrow={this.state.narrow} > - { - PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); - }} - > - -
{timeline}
+ + { + PosthogTrackers.trackInteraction("WebThreadViewBackButton", ev); + }} + > + +
{timeline}
- {ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( - - )} + {ContentMessages.sharedInstance().getCurrentUploads(threadRelation).length > 0 && ( + + )} - {this.state.thread?.timelineSet && ( - - )} -
+ {this.state.thread?.timelineSet && ( + + )} +
+ ); } 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/components/views/right_panel/TimelineCard.tsx b/apps/web/src/components/views/right_panel/TimelineCard.tsx index 0ec7b8bedd..3d5cabd1f9 100644 --- a/apps/web/src/components/views/right_panel/TimelineCard.tsx +++ b/apps/web/src/components/views/right_panel/TimelineCard.tsx @@ -38,6 +38,7 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo import Measured from "../elements/Measured"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { ScopedRoomContextProvider } from "../../../contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../viewmodels/room/RoomUploadViewModel.tsx"; import { EventPresentationContextProvider } from "../../../utils/EventPresentationContextProvider"; interface IProps { @@ -214,47 +215,49 @@ export default class TimelineCard extends React.Component { header={_t("right_panel|video_room_chat|title")} ref={this.card} > - -
- {jumpToBottom} - - -
- - {isUploading && } - - {showComposer && ( - - )} + )} + ); diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx index 2221bf7592..94919e7788 100644 --- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. @@ -14,7 +15,7 @@ import { THREAD_RELATION_TYPE, M_POLL_START, } from "matrix-js-sdk/src/matrix"; -import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext, useRef } from "react"; +import React, { type JSX, createContext, type ReactElement, type ReactNode, useContext } from "react"; import { AttachmentIcon, MicOnIcon, @@ -27,22 +28,19 @@ import { import { _t } from "../../../languageHandler"; import { CollapsibleButton } from "./CollapsibleButton"; import { type MenuProps } from "../../structures/ContextMenu"; -import dis from "../../../dispatcher/dispatcher"; import ErrorDialog from "../dialogs/ErrorDialog"; import { LocationButton } from "../location"; import Modal from "../../../Modal"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import ContentMessages from "../../../ContentMessages"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { useDispatcher } from "../../../hooks/useDispatcher"; -import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import { EmojiButton } from "./EmojiButton"; import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; +import { useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel.tsx"; interface IProps { addEmoji: (emoji: string) => boolean; @@ -126,7 +124,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { }); return ( - + <> {mainButtons} {moreButtons.length > 0 && ( = (props: IProps) => { )} - + ); }; @@ -168,79 +166,13 @@ function uploadButton(): ReactElement { return ; } -type UploadButtonFn = () => void; -export const UploadButtonContext = createContext(null); - -interface IUploadButtonProps { - roomId: string; - relation?: IEventRelation; - children: ReactNode; -} - -// We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. -const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { - const cli = useContext(MatrixClientContext); - const roomContext = useScopedRoomContext("timelineRenderingType", "replyToEvent"); - const uploadInput = useRef(null); - - const onUploadClick = (): void => { - if (cli?.isGuest()) { - dis.dispatch({ action: "require_registration" }); - return; - } - uploadInput.current?.click(); - }; - - useDispatcher(dis, (payload) => { - if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") { - onUploadClick(); - } - }); - - const onUploadFileInputChange = (ev: React.ChangeEvent): void => { - if (ev.target.files?.length === 0) return; - - // Take a copy, so we can safely reset the value of the form control - ContentMessages.sharedInstance().sendContentListToRoom( - Array.from(ev.target.files!), - roomId, - relation, - roomContext.replyToEvent, - cli, - roomContext.timelineRenderingType, - ); - - // This is the onChange handler for a file form control, but we're - // not keeping any state, so reset the value of the form control - // to empty. - // NB. we need to set 'value': the 'files' property is immutable. - ev.target.value = ""; - }; - - const uploadInputStyle = { display: "none" }; - return ( - - {children} - - - - ); -}; - // Must be rendered within an UploadButtonContextProvider const UploadButton: React.FC = () => { const overflowMenuCloser = useContext(OverflowMenuContext); - const uploadButtonFn = useContext(UploadButtonContext); + const vm = useRoomUploadViewModel(); const onClick = (): void => { - uploadButtonFn?.(); + vm.openUploadDialog(); overflowMenuCloser?.(); // close overflow menu }; diff --git a/apps/web/src/components/views/rooms/SendMessageComposer.tsx b/apps/web/src/components/views/rooms/SendMessageComposer.tsx index 0d9a68c45d..66288fbbaf 100644 --- a/apps/web/src/components/views/rooms/SendMessageComposer.tsx +++ b/apps/web/src/components/views/rooms/SendMessageComposer.tsx @@ -6,7 +6,13 @@ 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, { createRef, type KeyboardEvent, type SyntheticEvent } from "react"; +import React, { + createRef, + type KeyboardEvent, + type SyntheticEvent, + type RefAttributes, + type ReactElement, +} from "react"; import { type MatrixEvent, type IEventRelation, @@ -37,8 +43,7 @@ import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } import { findEditableEvent } from "../../../utils/EventUtils"; import SendHistoryManager from "../../../SendHistoryManager"; import { CommandCategories } from "../../../slash-commands/SlashCommands"; -import ContentMessages from "../../../ContentMessages"; -import { withMatrixClientHOC, type MatrixClientProps } from "../../../contexts/MatrixClientContext"; +import { useMatrixClientContext, type MatrixClientProps } from "../../../contexts/MatrixClientContext"; import { Action } from "../../../dispatcher/actions"; import { containsEmoji } from "../../../effects/utils"; import { CHAT_EFFECTS } from "../../../effects"; @@ -60,6 +65,7 @@ import { type IDiff } from "../../../editor/diff"; import { getBlobSafeMimeType } from "../../../utils/blobs"; import { EMOJI_REGEX } from "../../../HtmlUtils"; import { attachMentions, attachRelation } from "../../../utils/messages"; +import { type RoomUploadViewModel, useRoomUploadViewModel } from "../../../viewmodels/room/RoomUploadViewModel"; // The prefix used when persisting editor drafts to localstorage. export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_"; @@ -124,6 +130,7 @@ export function isQuickReaction(model: EditorModel): boolean { interface ISendMessageComposerProps extends MatrixClientProps { room: Room; + uploadVm: RoomUploadViewModel; placeholder?: string; relation?: IEventRelation; replyToEvent?: MatrixEvent; @@ -561,14 +568,7 @@ export class SendMessageComposer extends React.Component { console.log(error); @@ -660,5 +654,13 @@ export class SendMessageComposer extends React.Component & + RefAttributes>, +): ReactElement { + const client = useMatrixClientContext(); + const uploadVm = useRoomUploadViewModel(); + return ; +} + +export default SendMessageComposerWrapped; diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx index f45cc5d74f..8caf4c5eab 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import { type IEventRelation } from "matrix-js-sdk/src/matrix"; -import React, { type JSX, type RefObject, type ReactNode } from "react"; +import React, { type JSX, type RefObject, type ReactNode, useContext } from "react"; import { useComposerFunctions } from "../hooks/useComposerFunctions"; import { useIsFocused } from "../hooks/useIsFocused"; @@ -19,6 +19,7 @@ import { type ComposerFunctions } from "../types"; import { Editor } from "./Editor"; import { WysiwygAutocomplete } from "./WysiwygAutocomplete"; import { useSettingValue } from "../../../../../hooks/useSettings"; +import { RoomUploadContext } from "../../../../../viewmodels/room/RoomUploadViewModel"; interface PlainTextComposerProps { disabled?: boolean; @@ -46,6 +47,7 @@ export function PlainTextComposer({ eventRelation, }: PlainTextComposerProps): JSX.Element { const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); + const uploadContext = useContext(RoomUploadContext); const { ref: editorRef, autocompleteRef, @@ -61,7 +63,7 @@ export function PlainTextComposer({ handleMention, handleAtRoomMention, handleEmoji, - } = usePlainTextListeners(initialContent, onChange, onSend, eventRelation, isAutoReplaceEmojiEnabled); + } = usePlainTextListeners(initialContent, onChange, onSend, isAutoReplaceEmojiEnabled, uploadContext || undefined); const composerFunctions = useComposerFunctions(editorRef, setContent); usePlainTextInitialization(initialContent, editorRef); useSetCursorPosition(disabled, editorRef); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index b0696143aa..ab720e0283 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -60,7 +60,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({ const { room } = useScopedRoomContext("room"); const autocompleteRef = useRef(null); - const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent, eventRelation); + const inputEventProcessor = useInputEventProcessor(onSend, autocompleteRef, initialContent); const isAutoReplaceEmojiEnabled = useSettingValue("MessageComposerInput.autoReplaceEmoji"); const emojiSuggestions = useMemo(() => getEmojiSuggestions(isAutoReplaceEmojiEnabled), [isAutoReplaceEmojiEnabled]); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 30e5649013..d72f777218 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type Wysiwyg, type WysiwygEvent } from "@vector-im/matrix-wysiwyg"; import { useCallback } from "react"; -import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; @@ -26,13 +26,14 @@ import { endEditing } from "../utils/editing"; import type Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; +import { useRoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx"; export function useInputEventProcessor( onSend: () => void, autocompleteRef: React.RefObject, initialContent?: string, - eventRelation?: IEventRelation, ): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null { + const roomUploadVm = useRoomUploadViewModel(); const roomContext = useScopedRoomContext("liveTimeline", "room", "replyToEvent", "timelineRenderingType"); const composerContext = useComposerContext(); const mxClient = useMatrixClientContext(); @@ -52,7 +53,7 @@ export function useInputEventProcessor( if (isEventToHandleAsClipboardEvent(event)) { const data = event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer; - const handled = handleClipboardEvent(event, data, roomContext, mxClient, eventRelation); + const handled = handleClipboardEvent(event, data, roomUploadVm); return handled ? null : event; } @@ -77,11 +78,11 @@ export function useInputEventProcessor( isCtrlEnterToSend, onSend, initialContent, + roomUploadVm, roomContext, composerContext, mxClient, autocompleteRef, - eventRelation, ], ); } diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts index 81272f8b4b..1a4713503e 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import { type KeyboardEvent, type RefObject, type SyntheticEvent, useCallback, useRef, useState } from "react"; import { type AllowedMentionAttributes, type MappedSuggestion } from "@vector-im/matrix-wysiwyg"; -import { type IEventRelation } from "matrix-js-sdk/src/matrix"; import { useSettingValue } from "../../../../../hooks/useSettings"; import { IS_MAC, Key } from "../../../../../Keyboard"; @@ -16,8 +15,7 @@ import type Autocomplete from "../../Autocomplete"; import { handleClipboardEvent, handleEventWithAutocomplete, isEventToHandleAsClipboardEvent } from "./utils"; import { useSuggestion } from "./useSuggestion"; import { isNotNull, isNotUndefined } from "../../../../../Typeguards"; -import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; -import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx"; +import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel.tsx"; function isDivElement(target: EventTarget): target is HTMLDivElement { return target instanceof HTMLDivElement; @@ -46,8 +44,8 @@ export function usePlainTextListeners( initialContent?: string, onChange?: (content: string) => void, onSend?: () => void, - eventRelation?: IEventRelation, isAutoReplaceEmojiEnabled?: boolean, + roomUploadVM?: RoomUploadViewModel, ): { ref: RefObject; autocompleteRef: RefObject; @@ -64,9 +62,6 @@ export function usePlainTextListeners( onSelect: (this: void, event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const roomContext = useScopedRoomContext("room", "timelineRenderingType", "replyToEvent"); - const mxClient = useMatrixClientContext(); - const ref = useRef(null); const autocompleteRef = useRef(null); const [content, setContent] = useState(initialContent); @@ -120,10 +115,10 @@ export function usePlainTextListeners( const { nativeEvent } = event; let imagePasteWasHandled = false; - if (isEventToHandleAsClipboardEvent(nativeEvent)) { + if (roomUploadVM && isEventToHandleAsClipboardEvent(nativeEvent)) { const data = nativeEvent instanceof ClipboardEvent ? nativeEvent.clipboardData : nativeEvent.dataTransfer; - imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomContext, mxClient, eventRelation); + imagePasteWasHandled = handleClipboardEvent(nativeEvent, data, roomUploadVM); } // prevent default behaviour and skip call to onInput if the image paste event was handled @@ -133,7 +128,7 @@ export function usePlainTextListeners( onInput(event); } }, - [eventRelation, mxClient, onInput, roomContext], + [onInput, roomUploadVM], ); const enterShouldSend = !useSettingValue("MessageComposerInput.ctrlEnterToSend"); diff --git a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 0a34cf0940..6475109ab5 100644 --- a/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts +++ b/apps/web/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details. */ import { type RefObject } from "react"; -import { type IEventRelation, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type WysiwygEvent } from "@vector-im/matrix-wysiwyg"; import { type TimelineRenderingType } from "../../../../../contexts/RoomContext"; @@ -16,8 +15,8 @@ import type Autocomplete from "../../Autocomplete"; import { getKeyBindingsManager } from "../../../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts"; import { getBlobSafeMimeType } from "../../../../../utils/blobs"; -import ContentMessages from "../../../../../ContentMessages"; import { isNotNull } from "../../../../../Typeguards"; +import type { RoomUploadViewModel } from "../../../../../viewmodels/room/RoomUploadViewModel"; export function focusComposer( composerElement: RefObject, @@ -123,13 +122,8 @@ export function handleEventWithAutocomplete( export function handleClipboardEvent( event: ClipboardEvent | InputEvent, data: DataTransfer | null, - roomContext: Pick, - mxClient: MatrixClient, - eventRelation?: IEventRelation, + vm: RoomUploadViewModel, ): boolean { - // Logic in this function follows that of `SendMessageComposer.onPaste` - const { room, timelineRenderingType, replyToEvent } = roomContext; - function handleError(error: unknown): void { if (error instanceof Error) { console.log(error.message); @@ -138,7 +132,7 @@ export function handleClipboardEvent( } } - if (event.type !== "paste" || data === null || room === undefined) { + if (event.type !== "paste" || data === null) { return false; } @@ -147,16 +141,7 @@ export function handleClipboardEvent( // We check text/rtf instead of text/plain as when copy+pasting a file from Finder or Gnome Image Viewer // it puts the filename in as text/plain which we want to ignore. if (data.files.length && !data.types.includes("text/rtf")) { - ContentMessages.sharedInstance() - .sendContentListToRoom( - Array.from(data.files), - room.roomId, - eventRelation, - roomContext.replyToEvent, - mxClient, - timelineRenderingType, - ) - .catch(handleError); + vm.initiateViaDataTransfer(data).catch(handleError); return true; } @@ -188,9 +173,7 @@ export function handleClipboardEvent( const parts = response.url.split("/"); const filename = parts[parts.length - 1]; const file = new File([imgBlob], filename + "." + ext, { type: safetype }); - ContentMessages.sharedInstance() - .sendContentToRoom(file, room.roomId, eventRelation, mxClient, replyToEvent) - .catch(handleError); + return vm.initiateViaInputFiles([file]); }) .catch(handleError); }) 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/RoomUploadViewModel.tsx b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx new file mode 100644 index 0000000000..8b409e777b --- /dev/null +++ b/apps/web/src/viewmodels/room/RoomUploadViewModel.tsx @@ -0,0 +1,231 @@ +/* + * 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 { BaseViewModel, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; +import React, { + type ChangeEventHandler, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { + type MatrixClient, + type Room, + type IEventRelation, + type MatrixEvent, + RoomEvent, +} from "matrix-js-sdk/src/matrix"; + +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext"; +import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; +import ContentMessages from "../../ContentMessages"; +import type { TimelineRenderingType } from "../../contexts/RoomContext"; +import { chromeFileInputFix } from "../../utils/BrowserWorkarounds"; +import type { MatrixDispatcher } from "../../dispatcher/dispatcher"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +const logger = rootLogger.getChild("RoomUploadViewModel"); + +export interface RoomUploadViewSnapshot { + mayUpload: boolean; +} + +export interface RoomUploadViewActions { + initiateViaInputFiles(files: FileList | null): Promise; + initiateViaDataTransfer(dataTransfer: DataTransfer): Promise; + openUploadDialog(): void; +} + +export class RoomUploadViewModel + extends BaseViewModel> + implements RoomUploadViewActions +{ + public constructor( + private readonly room: Room, + private readonly client: MatrixClient, + private readonly timelineRenderingType: TimelineRenderingType, + private readonly dispatcher: MatrixDispatcher, + private replyToEvent: MatrixEvent | undefined, + private threadRelation: IEventRelation | undefined, + public readonly openUploadDialog: () => void, + ) { + super( + {}, + { + mayUpload: room.maySendMessage(), + }, + ); + room.on(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + this.disposables.track(() => { + room.off(RoomEvent.CurrentStateUpdated, this.onRoomCurrentStateUpdated); + }); + } + + private onRoomCurrentStateUpdated = (): void => { + this.snapshot.merge({ + mayUpload: this.room.maySendMessage(), + }); + }; + + public setReplyToEvent = (replyToEvent?: MatrixEvent): void => { + this.replyToEvent = replyToEvent; + }; + + public setThreadRelation = (threadRelation?: IEventRelation): void => { + this.threadRelation = threadRelation; + }; + + public initiateViaInputFiles = async (files: FileList | File[] | null): Promise => { + if (!this.checkCanUpload()) { + return; + } + const { roomId } = this.room; + logger.info("initiateViaInputFiles for", roomId); + if (!files?.length) return; + + try { + await ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(files), + roomId, + this.threadRelation, + this.replyToEvent, + this.client, + this.timelineRenderingType, + ); + } catch (ex) { + logger.warn("Failed to handle file upload transfer", ex); + } + }; + + public initiateViaDataTransfer = async (dataTransfer: DataTransfer): Promise => { + if (!this.checkCanUpload()) { + return; + } + const { roomId } = this.room; + logger.info("initiateViaDataTransfer for", roomId); + if (!dataTransfer.files?.length) return; + + try { + await ContentMessages.sharedInstance().sendContentListToRoom( + Array.from(dataTransfer.files), + roomId, + this.threadRelation, + this.replyToEvent, + this.client, + this.timelineRenderingType, + ); + } catch (ex) { + logger.warn("Failed to handle drag and drop data transfer", ex); + } + }; + + private checkCanUpload(): boolean { + if (this.client.isGuest()) { + this.dispatcher.dispatch({ action: "require_registration" }); + return false; + } + return true; + } +} + +export const RoomUploadContext = createContext(null); + +export function useRoomUploadViewModel(): RoomUploadViewModel { + const ctx = useContext(RoomUploadContext); + if (!ctx) { + throw new Error("RoomFileUploadProvider is not present"); + } + return ctx; +} + +export function RoomUploadContextProvider({ + children, + threadRelation, +}: { + children: ReactNode; + threadRelation?: IEventRelation; +}): ReactNode { + const { room, timelineRenderingType, replyToEvent } = useScopedRoomContext( + "room", + "timelineRenderingType", + "replyToEvent", + ); + const client = useMatrixClientContext(); + const uploadInput = useRef(null); + + const openFilePicker = useCallback((): void => { + if (!uploadInput.current) { + throw new Error("Input not ready"); + } + uploadInput.current.click(); + }, [uploadInput]); + + const vm = useCreateAutoDisposedViewModel(() => { + if (!room) { + throw new Error("RoomUploadContextProvider must have a room"); + } + return new RoomUploadViewModel( + room, + client, + timelineRenderingType, + defaultDispatcher, + replyToEvent, + threadRelation, + openFilePicker, + ); + }); + + useEffect(() => { + vm.setReplyToEvent(replyToEvent); + }, [vm, replyToEvent]); + + useEffect(() => { + vm.setThreadRelation(threadRelation); + }, [vm, threadRelation]); + + const onInputChange: ChangeEventHandler = useCallback( + (ev) => { + void (async () => { + try { + await vm.initiateViaInputFiles(ev.target.files); + } finally { + // This is the onChange handler for a file form control, but we're + // not keeping any state, so reset the value of the form control + // to empty. + // NB. we need to set 'value': the 'files' property is immutable. + ev.target.value = ""; + } + })(); + }, + [vm], + ); + + // Note, while this logic could be largely replaced with https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker + // it does not enjoy support across all our target platforms. + // Therefore, we use the invisible input element trick. + + return ( + + <> + {children} + + + + ); +} 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/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index 08fc1af63a..456a1e79b5 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -659,8 +659,8 @@ export function mkMessage({ export function mkStubRoom( roomId: string | null | undefined = null, - name: string | undefined, - client: MatrixClient | undefined, + name?: string | undefined, + client?: MatrixClient | undefined, state?: RoomState | undefined, ): Room { const stubTimeline = { diff --git a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx index 20b02f9fa5..2950f66239 100644 --- a/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx +++ b/apps/web/test/unit-tests/components/structures/FileDropTarget-test.tsx @@ -6,35 +6,62 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { mocked } from "jest-mock"; import { render, fireEvent } from "jest-matrix-react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { useMockedViewModel } from "@element-hq/web-shared-components"; import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx"; -import { stubClient } from "../../../test-utils"; +import { + RoomUploadContext, + type RoomUploadViewActions, + type RoomUploadViewModel, + type RoomUploadViewSnapshot, +} from "../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; + +function FileDropTargetWrapped({ + element, + snapshot, + actions, +}: { + element: HTMLDivElement; + snapshot: RoomUploadViewSnapshot; + actions: Partial; +}) { + const mockVm = useMockedViewModel( + snapshot, + actions as RoomUploadViewActions, + ); + return ( + + + + ); +} describe("FileDropTarget", () => { - let room: Room; - beforeEach(() => { - const client = stubClient(); - room = new Room("!roomId:example.com", client, client.getUserId()!); - room.currentState.maySendMessage = jest.fn().mockReturnValue(true); - }); - it("should render nothing when idle", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - const { asFragment } = render(); + const { asFragment } = render( + , + ); expect(asFragment()).toMatchSnapshot(); }); it("should render drop file prompt on mouse over with file if permissions allow", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - mocked(room.currentState.maySendMessage).mockReturnValue(true); - - const { asFragment } = render(); + const { asFragment } = render( + , + ); fireEvent.dragEnter(element, { dataTransfer: { types: ["Files"], @@ -46,9 +73,13 @@ describe("FileDropTarget", () => { it("should not render drop file prompt on mouse over with file if permissions do not allow", () => { const element = document.createElement("div"); const onFileDrop = jest.fn(); - mocked(room.currentState.maySendMessage).mockReturnValue(false); - - const { asFragment } = render(); + const { asFragment } = render( + , + ); fireEvent.dragEnter(element, { dataTransfer: { types: ["Files"], diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 0a6b8d1acc..d093f4da0e 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -270,6 +270,12 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
+ `; @@ -638,16 +644,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = /> - + `; @@ -1010,16 +1017,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t /> - + `; @@ -1320,6 +1328,12 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = ` /> + @@ -1621,15 +1635,16 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = ` /> - + @@ -2096,15 +2111,16 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa /> - + @@ -2571,15 +2587,16 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = ` /> - + @@ -2794,6 +2811,12 @@ exports[`RoomView should not display the timeline when the room encryption is lo /> + @@ -3288,15 +3311,16 @@ exports[`RoomView should not display the timeline when the room encryption is lo /> - + @@ -3844,15 +3868,16 @@ exports[`RoomView video rooms should render joined video room view 1`] = ` /> - +
diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 1d2d0ac657..b0f0c0d346 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -15,6 +15,8 @@ import { type IEventDecryptionResult, type MatrixClient, MatrixEvent, + MatrixEventEvent, + MsgType, NotificationCountType, PendingEventOrdering, Room, @@ -44,6 +46,59 @@ import PinningUtils from "../../../../../src/utils/PinningUtils"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; +import PlatformPeg from "../../../../../src/PlatformPeg"; + +function getTile(container: HTMLElement): HTMLElement { + const tile = container.querySelector(".mx_EventTile"); + expect(tile).not.toBeNull(); + return tile as HTMLElement; +} + +function getLine(container: HTMLElement): HTMLElement { + const line = container.querySelector(".mx_EventTile_line"); + expect(line).not.toBeNull(); + return line as HTMLElement; +} + +function expectTileClass(container: HTMLElement, className: string): void { + expect(getTile(container)).toHaveClass(className); +} + +function makeReplyEvent(roomId: string): MatrixEvent { + const parentEvent = mkMessage({ + room: roomId, + user: "@alice:example.org", + msg: "Original message", + event: true, + }); + + return mkMessage({ + room: roomId, + user: "@bob:example.org", + msg: "Reply message", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: parentEvent.getId(), + }, + }, + }); +} + +function makeThreadReplyEvent(roomId: string): MatrixEvent { + return mkMessage({ + room: roomId, + user: "@alice:example.org", + msg: "Hello world!", + ts: 1234, + event: true, + relatesTo: { + rel_type: "m.thread", + event_id: "$thread-root", + }, + }); +} describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; @@ -83,11 +138,35 @@ describe("EventTile", () => { return render(); } + function makeOwnMessage(overrides: Partial[0]> = {}): MatrixEvent { + return mkMessage({ + ...overrides, + room: overrides.room ?? room.roomId, + user: overrides.user ?? client.getSafeUserId(), + msg: overrides.msg ?? "Hello world!", + event: overrides.event ?? true, + }); + } + + function makeTimestampedMessage(overrides: Partial[0]> = {}): MatrixEvent { + return mkMessage({ + ...overrides, + room: overrides.room ?? room.roomId, + user: overrides.user ?? "@alice:example.org", + msg: overrides.msg ?? "Hello world!", + ts: overrides.ts ?? 1234, + event: overrides.event ?? true, + }); + } + beforeEach(() => { jest.clearAllMocks(); stubClient(); client = MatrixClientPeg.safeGet(); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn().mockReturnValue(undefined), + } as unknown as DMRoomMap); room = new Room(ROOM_ID, client, client.getSafeUserId(), { pendingEventOrdering: PendingEventOrdering.Detached, @@ -110,6 +189,550 @@ describe("EventTile", () => { jest.restoreAllMocks(); }); + describe("layout and tile attributes", () => { + it.each([ + ["last", { last: true }, "mx_EventTile_last"], + ["lastInSection", { lastInSection: true }, "mx_EventTile_lastInSection"], + ["contextual", { contextual: true }, "mx_EventTile_contextual"], + ["isSelectedEvent", { isSelectedEvent: true }, "mx_EventTile_selected"], + ["hideSender", { hideSender: true }, "mx_EventTile_noSender"], + ["isTwelveHour", { isTwelveHour: true }, "mx_EventTile_12hr"], + ] as const)("adds the %s class", (_propName, overrides, className) => { + const { container } = getComponent(overrides); + + expectTileClass(container, className); + }); + + it("marks events from other users as non-self events", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-self", "false"); + }); + + it("marks events from the current user as self events", () => { + const ownEvent = makeOwnMessage(); + const { container } = getComponent({ mxEvent: ownEvent }); + + expect(getTile(container)).toHaveAttribute("data-self", "true"); + }); + + it("exposes the rendered event id in room timelines", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-event-id", mxEvent.getId()); + }); + + it("renders the event line inside the tile", () => { + const { container } = getComponent(); + + expect(getTile(container)).toContainElement(getLine(container)); + }); + + it("does not expose a scroll token for local echo events", () => { + const localEcho = makeOwnMessage(); + localEcho.setStatus(EventStatus.SENDING); + const { container } = getComponent({ mxEvent: localEcho, eventSendStatus: EventStatus.SENDING }); + + expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens"); + }); + }); + + describe("rendering root attributes", () => { + type RootAttribute = + | "data-scroll-tokens" + | "data-layout" + | "data-shape" + | "data-self" + | "data-event-id" + | "data-has-reply"; + + it.each([ + [ + TimelineRenderingType.Room, + ["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"], + ["data-shape"], + ], + [ + TimelineRenderingType.Thread, + ["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"], + ["data-shape"], + ], + [ + TimelineRenderingType.ThreadsList, + ["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"], + ["data-event-id"], + ], + [ + TimelineRenderingType.Notification, + ["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"], + ["data-event-id"], + ], + [ + TimelineRenderingType.File, + ["data-scroll-tokens"], + ["data-layout", "data-shape", "data-self", "data-event-id", "data-has-reply"], + ], + ] as const)( + "sets root attributes for %s rendering", + (renderingType, expectedPresentAttributes, expectedAbsentAttributes) => { + const { container } = getComponent({}, renderingType); + const tile = getTile(container); + const expectedValues: Record = { + "data-scroll-tokens": mxEvent.getId()!, + "data-layout": Layout.Group, + "data-shape": renderingType, + "data-self": "false", + "data-event-id": mxEvent.getId()!, + "data-has-reply": "false", + }; + + for (const attribute of expectedPresentAttributes) { + expect(tile).toHaveAttribute(attribute, expectedValues[attribute]); + } + + for (const attribute of expectedAbsentAttributes) { + expect(tile).not.toHaveAttribute(attribute); + } + }, + ); + }); + + describe("message type classes", () => { + it("adds media and image classes for image messages", () => { + const imageEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: room.roomId, + user: "@alice:example.org", + content: { + msgtype: MsgType.Image, + body: "image.png", + url: "mxc://example.org/image", + info: { + mimetype: "image/png", + w: 100, + h: 100, + size: 1234, + }, + }, + }); + const { container } = getComponent({ mxEvent: imageEvent }); + + expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine"); + expect(getLine(container)).toHaveClass("mx_EventTile_image"); + }); + + it("adds emote classes for emote messages", () => { + const emoteEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: room.roomId, + user: "@alice:example.org", + content: { + msgtype: MsgType.Emote, + body: "waves", + }, + }); + const { container } = getComponent({ mxEvent: emoteEvent }); + + expect(getTile(container)).toHaveClass("mx_EventTile_emote"); + expect(getLine(container)).toHaveClass("mx_EventTile_emote"); + }); + + it("adds media and sticker classes for sticker events", () => { + const stickerEvent = mkEvent({ + event: true, + type: EventType.Sticker, + room: room.roomId, + user: "@alice:example.org", + content: { + body: "sticker.png", + url: "mxc://example.org/sticker", + info: { + mimetype: "image/png", + w: 100, + h: 100, + size: 1234, + }, + }, + }); + const { container } = getComponent({ mxEvent: stickerEvent }); + + expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine"); + expect(getLine(container)).toHaveClass("mx_EventTile_sticker"); + }); + }); + + describe("timestamps", () => { + beforeEach(() => { + mxEvent = makeTimestampedMessage(); + }); + + it("hides the timestamp by default in room timelines", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + }); + + it("shows the timestamp when the tile is hovered", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp when focus is within the tile", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.focus(getTile(container)); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp for the last event", () => { + const { container } = getComponent({ last: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp when timestamps are always shown", () => { + const { container } = getComponent({ alwaysShowTimestamps: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("hides the timestamp when timestamps are disabled for the tile", () => { + const { container } = getComponent({ alwaysShowTimestamps: true, hideTimestamp: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + }); + + it("renders a placeholder timestamp in IRC layout", () => { + const { container } = getComponent({ layout: Layout.IRC }); + const timestamp = container.querySelector(".mx_MessageTimestamp"); + + expect(timestamp).not.toBeNull(); + expect(timestamp?.tagName).toBe("SPAN"); + }); + + it("dispatches a room view when the linked timestamp is clicked", () => { + jest.spyOn(dis, "dispatch").mockImplementation(() => {}); + const permalinkCreator = new RoomPermalinkCreator(room); + const { container } = getComponent({ alwaysShowTimestamps: true, permalinkCreator }); + const timestamp = container.querySelector("a.mx_MessageTimestamp"); + + expect(timestamp).not.toBeNull(); + fireEvent.click(timestamp!); + + expect(dis.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: room.roomId, + }), + ); + }); + }); + + describe("sender and avatar rendering", () => { + it("shows sender and avatar in room timelines", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("hides sender and avatar for continuation events in room timelines", () => { + const { container } = getComponent({ continuation: true }); + + expectTileClass(container, "mx_EventTile_continuation"); + expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).toBeNull(); + }); + + it("hides sender but keeps avatar when sender display is disabled", () => { + const { container } = getComponent({ hideSender: true }); + + expectTileClass(container, "mx_EventTile_noSender"); + expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("renders sender details as a permalink in file timelines", () => { + const { container } = getComponent({}, TimelineRenderingType.File); + const senderDetailsLink = container.querySelector(".mx_EventTile_senderDetailsLink"); + + expect(senderDetailsLink).not.toBeNull(); + expect(senderDetailsLink).toContainElement(container.querySelector(".mx_DisambiguatedProfile")); + expect(senderDetailsLink).toContainElement(container.querySelector(".mx_EventTile_avatar")); + }); + + it("renders sender details in thread timelines", () => { + const { container } = getComponent({}, TimelineRenderingType.Thread); + const senderDetails = container.querySelector(".mx_EventTile_senderDetails"); + + expect(senderDetails).not.toBeNull(); + expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile")); + expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar")); + }); + }); + + describe("read receipt option", () => { + it("shows a sent receipt for the current user's last successful event", () => { + const ownEvent = makeOwnMessage(); + const { getByRole } = getComponent({ mxEvent: ownEvent, lastSuccessful: true }); + + expect(getByRole("status")).toHaveAccessibleName("Your message was sent"); + }); + + it.each([ + [EventStatus.SENDING, "Sending your message…"], + [EventStatus.ENCRYPTING, "Encrypting your message…"], + [EventStatus.NOT_SENT, "Failed to send"], + ])("shows the %s receipt for the current user's pending event", (eventSendStatus, label) => { + const ownEvent = makeOwnMessage(); + ownEvent.setStatus(eventSendStatus); + const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus }); + + expect(getByRole("status")).toHaveAccessibleName(label); + }); + + it("does not show a sent receipt in the threads list", () => { + const ownEvent = makeOwnMessage(); + const { queryByRole } = getComponent( + { mxEvent: ownEvent, lastSuccessful: true }, + TimelineRenderingType.ThreadsList, + ); + + expect(queryByRole("status", { name: "Your message was sent" })).toBeNull(); + }); + + it("shows normal read receipts instead of the sent receipt when other users have read the event", () => { + const ownEvent = makeOwnMessage(); + const { getByRole, queryByRole } = getComponent({ + mxEvent: ownEvent, + lastSuccessful: true, + showReadReceipts: true, + readReceipts: [ + { + userId: "@bob:example.org", + roomMember: null, + ts: 1234, + }, + ], + }); + + expect(queryByRole("status", { name: "Your message was sent" })).toBeNull(); + expect(getByRole("group", { name: "Seen by 1 person" })).toBeInTheDocument(); + }); + }); + + describe("reactions and footer", () => { + it("gets annotation relations when reactions are enabled", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + + getComponent({ showReactions: true, getRelationsForEvent }); + + expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction"); + }); + + it("does not get annotation relations when reactions are disabled", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + + getComponent({ getRelationsForEvent }); + + expect(getRelationsForEvent).not.toHaveBeenCalled(); + }); + + it("refreshes annotation relations when reaction relations are created", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + getComponent({ showReactions: true, getRelationsForEvent }); + getRelationsForEvent.mockClear(); + + act(() => { + mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.annotation", "m.reaction"); + }); + + expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction"); + }); + + it("does not refresh annotation relations for unrelated relations", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + getComponent({ showReactions: true, getRelationsForEvent }); + getRelationsForEvent.mockClear(); + + act(() => { + mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", "m.room.message"); + }); + + expect(getRelationsForEvent).not.toHaveBeenCalled(); + }); + + it("does not render reactions for redacted events", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + const { container } = getComponent({ showReactions: true, getRelationsForEvent, isRedacted: true }); + + expect(container.querySelector(".mx_ReactionsRow")).toBeNull(); + }); + + it("renders a footer for pinned messages", () => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + const { container } = getComponent(); + + expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull(); + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }); + }); + + describe("action bar", () => { + it("does not render the message action bar by default", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("renders the message action bar when the tile is hovered", () => { + const { container } = getComponent(); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + }); + + it("renders the message action bar when the tile receives keyboard focus", () => { + const matches = HTMLElement.prototype.matches; + jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) { + if (selector === ":focus-visible") return true; + return matches.call(this, selector); + }); + const { container } = getComponent(); + + fireEvent.focus(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + }); + + it("hides the keyboard-focused message action bar when focus leaves the tile", () => { + const matches = HTMLElement.prototype.matches; + jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) { + if (selector === ":focus-visible") return true; + return matches.call(this, selector); + }); + const { container } = getComponent(); + const tile = getTile(container); + + fireEvent.focus(tile); + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + + fireEvent.blur(tile); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("does not render the message action bar on hover when exporting", () => { + const { container } = getComponent({ forExport: true }); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("does not render the message action bar on hover while editing", () => { + const { container } = getComponent({ editState: {} as EventTileProps["editState"] }); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + }); + + describe("context menu", () => { + it("renders the message context menu when the event line is right-clicked", async () => { + const { container } = getComponent(); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + }); + + it("marks the tile selected when the context menu is open", async () => { + const { container } = getComponent(); + const tile = getTile(container); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + expect(tile).toHaveClass("mx_EventTile_selected"); + }); + + it("shows the timestamp while the context menu is open", async () => { + mxEvent = makeTimestampedMessage(); + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("does not render the message context menu while editing", () => { + const { container } = getComponent({ editState: {} as EventTileProps["editState"] }); + + expect(container.querySelector(".mx_EventTile_line")).toBeNull(); + expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull(); + }); + + it("does not override the native browser context menu for links", () => { + const { container } = getComponent(); + jest.spyOn(PlatformPeg, "get").mockReturnValue({ + allowOverridingNativeContextMenus: () => false, + } as ReturnType); + const link = document.createElement("a"); + link.href = "https://example.org/"; + getLine(container).appendChild(link); + + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 1, clientY: 2 }); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull(); + }); + }); + + describe("reply chain", () => { + it("marks non-reply events as having no reply", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "false"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull(); + }); + + it("marks reply events as having a reply chain", () => { + const replyEvent = makeReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: replyEvent }); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "true"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).not.toBeNull(); + }); + + it("does not render the reply chain for redacted reply events", () => { + const replyEvent = makeReplyEvent(room.roomId); + jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true); + const { container } = getComponent({ mxEvent: replyEvent }); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "false"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull(); + }); + }); + describe("EventTile thread summary", () => { beforeEach(() => { jest.spyOn(client, "supportsThreads").mockReturnValue(true); @@ -150,6 +773,43 @@ describe("EventTile", () => { }); }); + describe("search thread info", () => { + it("renders search thread info for events in a thread", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search); + + expect(container.querySelector(".mx_ThreadSummary_icon")).not.toBeNull(); + expect(container.querySelector(".mx_ThreadSummary_icon")).toHaveTextContent("From a thread"); + }); + + it("renders search thread info as a link when a highlight link is provided", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent( + { mxEvent: threadEvent, highlightLink: "https://example.org/thread" }, + TimelineRenderingType.Search, + ); + const threadInfo = container.querySelector("a.mx_ThreadSummary_icon"); + + expect(threadInfo).not.toBeNull(); + expect(threadInfo).toHaveAttribute("href", "https://example.org/thread"); + }); + + it("renders search thread info as text when no highlight link is provided", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search); + const threadInfo = container.querySelector(".mx_ThreadSummary_icon"); + + expect(threadInfo?.tagName).toBe("P"); + }); + + it("does not render search thread info outside search timelines", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Room); + + expect(container.querySelector(".mx_ThreadSummary_icon")).toBeNull(); + }); + }); + describe("EventTile renderingType: ThreadsList", () => { it("shows an unread notification badge", () => { const { container } = getComponent({}, TimelineRenderingType.ThreadsList); @@ -246,13 +906,6 @@ describe("EventTile", () => { }); describe("EventTile in the right panel", () => { - beforeAll(() => { - const dmRoomMap: DMRoomMap = { - getUserIdForRoomId: jest.fn(), - } as unknown as DMRoomMap; - DMRoomMap.setShared(dmRoomMap); - }); - it("renders the room name for notifications", () => { const { container } = getComponent({}, TimelineRenderingType.Notification); expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( @@ -600,6 +1253,43 @@ describe("EventTile", () => { expect(isHighlighted(container)).toBeFalsy(); }); + it("does not highlight when exporting", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const { container } = getComponent({ forExport: true }); + + expect(client.getPushActionsForEvent).not.toHaveBeenCalled(); + expect(isHighlighted(container)).toBeFalsy(); + }); + + it.each([TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])( + "does not highlight in %s timelines", + (renderingType) => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const { container } = getComponent({}, renderingType); + + expect(client.getPushActionsForEvent).not.toHaveBeenCalled(); + expect(isHighlighted(container)).toBeFalsy(); + }, + ); + + it("does not highlight events sent by the current user", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const ownEvent = makeOwnMessage(); + const { container } = getComponent({ mxEvent: ownEvent }); + + expect(client.getPushActionsForEvent).toHaveBeenCalledWith(ownEvent); + expect(isHighlighted(container)).toBeFalsy(); + }); + it("highlights when message's push actions have a highlight tweak", () => { mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, diff --git a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx index bfd9c88448..078b8c97c2 100644 --- a/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/MessageComposer-test.tsx @@ -36,6 +36,10 @@ import UIStore, { UI_EVENTS } from "../../../../../src/stores/UIStore"; import { Action } from "../../../../../src/dispatcher/actions"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { + RoomUploadContext, + type RoomUploadViewModel, +} from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; const openStickerPicker = async (): Promise => { await userEvent.click(screen.getByLabelText("More options")); @@ -469,7 +473,9 @@ function wrapAndRender( const getRawComponent = (props = {}, context = roomContext, client = mockClient) => ( - + + + ); diff --git a/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx b/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx index 58a58185d0..0f29c546ec 100644 --- a/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/MessageComposerButtons-test.tsx @@ -14,7 +14,8 @@ import { createTestClient, getRoomContext, mkStubRoom } from "../../../../test-u import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import MessageComposerButtons from "../../../../../src/components/views/rooms/MessageComposerButtons"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; -import type { RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts"; +import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; describe("MessageComposerButtons", () => { // @ts-ignore - we're deliberately not implementing the whole interface here, but @@ -54,7 +55,9 @@ describe("MessageComposerButtons", () => { return render( - {component} + + {component} + , ); } diff --git a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx index c6e5109406..2d616027ff 100644 --- a/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/SendMessageComposer-test.tsx @@ -31,6 +31,7 @@ import { doMaybeLocalRoomAction } from "../../../../../src/utils/local-room"; import { addTextToComposer } from "../../../../test-utils/composer"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import { SdkContextClass } from "../../../../../src/contexts/SDKContext.ts"; +import { RoomUploadContextProvider } from "../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; jest.mock("../../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -187,8 +188,10 @@ describe("", () => { }; const getRawComponent = (props = {}, roomContext = defaultRoomContext, client = mockClient) => ( - - + + + + ); @@ -435,7 +438,11 @@ describe("", () => { const { container } = render( - + + + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index 3293829686..fd47666269 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -27,6 +27,7 @@ import { type ActionPayload } from "../../../../../../src/dispatcher/payloads"; import * as EmojiButton from "../../../../../../src/components/views/rooms/EmojiButton"; import { createMocks } from "./utils"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; beforeAll(initOnce, 10000); @@ -46,7 +47,9 @@ describe("EditWysiwygComposer", () => { return render( - + + + , ); @@ -62,7 +65,9 @@ describe("EditWysiwygComposer", () => { rerender( - + + + , ); @@ -273,7 +278,9 @@ describe("EditWysiwygComposer", () => { render( - + + + , diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 528615939a..0116c6e8e3 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -25,6 +25,7 @@ import { setSelection } from "../../../../../../src/components/views/rooms/wysiw import { createMocks } from "./utils"; import { ScopedRoomContextProvider } from "../../../../../../src/contexts/ScopedRoomContext.tsx"; import { E2EStatus } from "../../../../../../src/utils/ShieldUtils.ts"; +import { RoomUploadContextProvider } from "../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; jest.mock("../../../../../../src/components/views/rooms/EmojiButton", () => ({ EmojiButton: ({ addEmoji }: { addEmoji: (emoji: string) => void }) => { @@ -77,15 +78,17 @@ describe("SendWysiwygComposer", () => { return render( - + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index ce4eb8634f..0a98d04969 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -33,6 +33,7 @@ import type AutocompleteProvider from "../../../../../../../src/autocomplete/Aut import * as Permalinks from "../../../../../../../src/utils/permalinks/Permalinks"; import { type PermalinkParts } from "../../../../../../../src/utils/permalinks/PermalinkConstructor"; import { ScopedRoomContextProvider } from "../../../../../../../src/contexts/ScopedRoomContext.tsx"; +import { RoomUploadContextProvider } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel.tsx"; beforeAll(initOnce, 10000); @@ -42,12 +43,14 @@ describe("WysiwygComposer", () => { return render( - + + + , ); @@ -561,19 +564,21 @@ describe("WysiwygComposer", () => { return render( - - - + + + + + , ); diff --git a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx index 3627513c20..ff8c622426 100644 --- a/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/wysiwyg_composer/hooks/utils-test.tsx @@ -5,29 +5,21 @@ Copyright 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 { type IEventRelation, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { waitFor } from "jest-matrix-react"; import fetchMock from "@fetch-mock/jest"; -import { TimelineRenderingType } from "../../../../../../../src/contexts/RoomContext"; -import { mkStubRoom, stubClient } from "../../../../../../test-utils"; -import ContentMessages from "../../../../../../../src/ContentMessages"; -import { type IRoomState } from "../../../../../../../src/components/structures/RoomView"; import { handleClipboardEvent, isEventToHandleAsClipboardEvent, } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/utils"; +import type { RoomUploadViewModel } from "../../../../../../../src/viewmodels/room/RoomUploadViewModel"; +import type { MockedObject } from "jest-mock"; -const mockClient = stubClient(); -const mockRoom = mkStubRoom("mock room", "mock room", mockClient); -const mockRoomState = { - room: mockRoom, - timelineRenderingType: TimelineRenderingType.Room, - replyToEvent: {} as unknown as MatrixEvent, -} as unknown as IRoomState; +const mockUploadVM = { + initiateViaDataTransfer: jest.fn().mockResolvedValue(undefined), + initiateViaInputFiles: jest.fn().mockResolvedValue(undefined), +} as Partial as MockedObject; -const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom"); -const sendContentToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentToRoom"); const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); describe("handleClipboardEvent", () => { @@ -45,29 +37,16 @@ describe("handleClipboardEvent", () => { it("returns false if it is not a paste event", () => { const originalEvent = createMockClipboardEvent({ type: "copy" }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("returns false if clipboard data is null", () => { const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: null }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - - expect(output).toBe(false); - }); - - it("returns false if room is undefined", () => { - const originalEvent = createMockClipboardEvent({ type: "paste" }); - const { room, ...roomStateWithoutRoom } = mockRoomState; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - roomStateWithoutRoom, - mockClient, - ); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("returns false if room clipboardData files and types are empty", () => { @@ -75,8 +54,9 @@ describe("handleClipboardEvent", () => { type: "paste", clipboardData: { files: [], types: [] }, }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(output).toBe(false); + expect(mockUploadVM.initiateViaDataTransfer).not.toHaveBeenCalled(); }); it("handles event and calls sendContentListToRoom when data files are present", () => { @@ -84,65 +64,23 @@ describe("handleClipboardEvent", () => { type: "paste", clipboardData: { files: ["something here"], types: [] }, }); - const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient); - const mockReplyToEvent = {} as unknown as MatrixEvent; - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); - expect(sendContentListToRoomSpy).toHaveBeenCalledWith( - originalEvent.clipboardData?.files, - mockRoom.roomId, - undefined, // this is the event relation, an optional arg - mockReplyToEvent, - mockClient, - mockRoomState.timelineRenderingType, - ); - expect(output).toBe(true); - }); - - it("calls sendContentListToRoom with eventRelation when present", () => { - const originalEvent = createMockClipboardEvent({ - type: "paste", - clipboardData: { files: ["something here"], types: [] }, - }); - const mockEventRelation = {} as unknown as IEventRelation; - const mockReplyToEvent = {} as unknown as MatrixEvent; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); - - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); - expect(sendContentListToRoomSpy).toHaveBeenCalledWith( - originalEvent.clipboardData?.files, - mockRoom.roomId, - mockEventRelation, // this is the event relation, an optional arg - mockReplyToEvent, - mockClient, - mockRoomState.timelineRenderingType, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledWith(originalEvent.clipboardData); expect(output).toBe(true); }); it("calls the error handler when sentContentListToRoom errors", async () => { const mockErrorMessage = "something went wrong"; - sendContentListToRoomSpy.mockRejectedValueOnce(new Error(mockErrorMessage)); + mockUploadVM.initiateViaDataTransfer.mockRejectedValueOnce(new Error(mockErrorMessage)); const originalEvent = createMockClipboardEvent({ type: "paste", clipboardData: { files: ["something here"], types: [] }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); - expect(sendContentListToRoomSpy).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaDataTransfer).toHaveBeenCalledTimes(1); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); }); @@ -158,15 +96,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue("
invalid html"), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); - + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(logSpy).toHaveBeenCalledWith("Failed to handle pasted content as Safari inserted content"); expect(output).toBe(false); }); @@ -180,10 +110,10 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockRoomState, mockClient, mockEventRelation); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); expect(fetchMock).toHaveFetchedTimes(1, "blob:"); + expect(output).toBe(true); }); it("calls error handler when fetch fails", async () => { @@ -197,14 +127,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); @@ -212,7 +135,7 @@ describe("handleClipboardEvent", () => { expect(output).toBe(true); }); - it("calls sendContentToRoom when parsing is successful", async () => { + it("calls initiateViaInputFiles when parsing is successful", async () => { fetchMock.get("test/file", { blob: () => { return Promise.resolve({ type: "image/jpeg" } as Blob); @@ -227,23 +150,11 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { - expect(sendContentToRoomSpy).toHaveBeenCalledWith( - expect.any(File), - mockRoom.roomId, - mockEventRelation, - mockClient, - mockRoomState.replyToEvent, - ); + expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledTimes(1); + expect(mockUploadVM.initiateViaInputFiles).toHaveBeenCalledWith([expect.any(File)]); }); expect(output).toBe(true); }); @@ -254,8 +165,8 @@ describe("handleClipboardEvent", () => { return Promise.resolve({ type: "image/jpeg" } as Blob); }, }); - const mockErrorMessage = "sendContentToRoom failed"; - sendContentToRoomSpy.mockRejectedValueOnce(mockErrorMessage); + const mockErrorMessage = "initiateViaInputFiles failed"; + mockUploadVM.initiateViaInputFiles.mockRejectedValueOnce(mockErrorMessage); const originalEvent = createMockClipboardEvent({ type: "paste", @@ -265,14 +176,7 @@ describe("handleClipboardEvent", () => { getData: jest.fn().mockReturnValue(``), }, }); - const mockEventRelation = {} as unknown as IEventRelation; - const output = handleClipboardEvent( - originalEvent, - originalEvent.clipboardData, - mockRoomState, - mockClient, - mockEventRelation, - ); + const output = handleClipboardEvent(originalEvent, originalEvent.clipboardData, mockUploadVM); await waitFor(() => { expect(logSpy).toHaveBeenCalledWith(mockErrorMessage); 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/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts new file mode 100644 index 0000000000..837eaacc62 --- /dev/null +++ b/apps/web/test/viewmodels/room/RoomUploadViewModel-test.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) 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 IEventRelation, type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; + +import type { MockedObject } from "jest-mock"; +import { RoomUploadViewModel } from "../../../src/viewmodels/room/RoomUploadViewModel"; +import { mkEvent, mkStubRoom, stubClient } from "../../test-utils"; +import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; +import type { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import ContentMessages from "../../../src/ContentMessages"; +const sendContentListToRoomSpy = jest.spyOn(ContentMessages.sharedInstance(), "sendContentListToRoom"); + +describe("RoomUploadViewModel", () => { + let client: MockedObject; + let room: MockedObject; + let dis: MockedObject; + beforeEach(() => { + jest.clearAllMocks(); + client = stubClient() as MockedObject; + room = mkStubRoom("!room", undefined, undefined) as MockedObject; + dis = { + dispatch: jest.fn(), + } as Partial as MockedObject; + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + + it.each([true, false])("handles state of mayUpload when room.maySendMessage = %s", (maySendMessage) => { + room.maySendMessage.mockReturnValue(maySendMessage); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + expect(vm.getSnapshot().mayUpload).toEqual(maySendMessage); + room.maySendMessage.mockReturnValue(!maySendMessage); + room.emit(RoomEvent.CurrentStateUpdated, room, null as any, null as any); + expect(vm.getSnapshot().mayUpload).toEqual(!maySendMessage); + }); + + describe("uploads via input", () => { + it("redirected if guest", async () => { + client.isGuest.mockReturnValue(true); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaInputFiles([] as unknown as FileList); + expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" }); + }); + it("skips empty files", async () => { + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaInputFiles([] as unknown as FileList); + expect(dis.dispatch).not.toHaveBeenCalled(); + }); + it("uploads with correct context", async () => { + sendContentListToRoomSpy.mockResolvedValue(undefined); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Thread, + dis, + undefined, + undefined, + () => {}, + ); + const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} }); + vm.setReplyToEvent(replyEvent); + const threadRelation: IEventRelation = { key: "foo" }; + vm.setThreadRelation(threadRelation); + const fileList = [ + { + name: "fake.png", + size: 1024, + type: "image/png", + }, + ] as unknown as FileList; + await vm.initiateViaInputFiles(fileList); + expect(sendContentListToRoomSpy).toHaveBeenCalledWith( + fileList, + room.roomId, + threadRelation, + replyEvent, + client, + TimelineRenderingType.Thread, + ); + }); + }); + + describe("uploads via data transfer", () => { + it("redirected if guest", async () => { + client.isGuest.mockReturnValue(true); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaDataTransfer({} as DataTransfer); + expect(dis.dispatch).toHaveBeenCalledWith({ action: "require_registration" }); + }); + it("skips empty files", async () => { + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Room, + dis, + undefined, + undefined, + () => {}, + ); + await vm.initiateViaDataTransfer({ files: [] as unknown as FileList } as DataTransfer); + expect(dis.dispatch).not.toHaveBeenCalled(); + }); + it("uploads with correct context", async () => { + sendContentListToRoomSpy.mockResolvedValue(undefined); + const vm = new RoomUploadViewModel( + room, + client, + TimelineRenderingType.Thread, + dis, + undefined, + undefined, + () => {}, + ); + const replyEvent = mkEvent({ event: true, type: "anything", user: "anyone", content: {} }); + vm.setReplyToEvent(replyEvent); + const threadRelation: IEventRelation = { key: "foo" }; + vm.setThreadRelation(threadRelation); + const files = [ + { + name: "fake.png", + size: 1024, + type: "image/png", + }, + ] as unknown as FileList; + await vm.initiateViaDataTransfer({ files } as DataTransfer); + expect(sendContentListToRoomSpy).toHaveBeenCalledWith( + files, + room.roomId, + threadRelation, + replyEvent, + client, + TimelineRenderingType.Thread, + ); + }); + }); +}); 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"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0aeef1ee5..8eecf981a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,7 +463,7 @@ importers: version: 1.0.3 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9 matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -1530,7 +1530,6 @@ packages: '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -2822,7 +2821,6 @@ packages: '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -2830,7 +2828,6 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead '@iconify-json/simple-icons@1.2.75': resolution: {integrity: sha512-KvcCUbvcBWb0sbqLIxHoY8z5/piXY08wcY9gfMhF+ph3AfzGMaSmZFkUY71HSXAljQngXkgs4bdKdekO0HQWvg==} @@ -5752,7 +5749,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -6649,7 +6645,6 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -8183,7 +8178,6 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@10.4.0: @@ -8611,7 +8605,6 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.6: @@ -8620,7 +8613,6 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -8985,7 +8977,6 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9927,9 +9918,9 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32} - version: 41.4.0 + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9} + version: 41.5.0 engines: {node: '>=22.0.0'} matrix-web-i18n@3.6.0: @@ -11365,7 +11356,6 @@ packages: react-beautiful-dnd@13.1.1: resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} - deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' peerDependencies: react: ^16.8.5 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 @@ -11701,12 +11691,10 @@ packages: rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@6.1.3: @@ -12903,7 +12891,6 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.1: @@ -12916,7 +12903,6 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -13270,7 +13256,6 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -23486,7 +23471,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0