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 0c902fee3c..a215241bbf 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -226,17 +226,14 @@ @import "./views/messages/_MFileBody.pcss"; @import "./views/messages/_MImageBody.pcss"; @import "./views/messages/_MImageReplyBody.pcss"; -@import "./views/messages/_MJitsiWidgetEvent.pcss"; @import "./views/messages/_MLocationBody.pcss"; @import "./views/messages/_MPollBody.pcss"; @import "./views/messages/_MStickerBody.pcss"; @import "./views/messages/_MediaBody.pcss"; @import "./views/messages/_MessageActionBar.pcss"; @import "./views/messages/_ReactionsRow.pcss"; -@import "./views/messages/_RoomAvatarEvent.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/_MJitsiWidgetEvent.pcss b/apps/web/res/css/views/messages/_MJitsiWidgetEvent.pcss deleted file mode 100644 index 7813354366..0000000000 --- a/apps/web/res/css/views/messages/_MJitsiWidgetEvent.pcss +++ /dev/null @@ -1,13 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 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_EventTileBubble.mx_MJitsiWidgetEvent { - svg { - color: $header-panel-text-primary-color; /* XXX: Variable abuse */ - } -} 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 54727b95e2..87303d3f8c 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/MJitsiWidgetEvent.tsx b/apps/web/src/components/views/messages/MJitsiWidgetEvent.tsx deleted file mode 100644 index dfed9d4378..0000000000 --- a/apps/web/src/components/views/messages/MJitsiWidgetEvent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX } from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { EventTileBubble } from "@element-hq/web-shared-components"; - -import { _t } from "../../../languageHandler"; -import WidgetStore from "../../../stores/WidgetStore"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; - -interface IProps { - mxEvent: MatrixEvent; - timestamp?: JSX.Element; -} - -export default class MJitsiWidgetEvent extends React.PureComponent { - public render(): React.ReactNode { - const url = this.props.mxEvent.getContent()["url"]; - const prevUrl = this.props.mxEvent.getPrevContent()["url"]; - const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender(); - const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - if (!room) return null; - const widgetId = this.props.mxEvent.getStateKey(); - const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find((w) => w.id === widgetId); - - let joinCopy: string | null = _t("timeline|m.widget|jitsi_join_top_prompt"); - if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) { - joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt"); - } else if (!widget) { - joinCopy = null; - } - - if (!url) { - // removed - return ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_ended", { senderName })} - > - {this.props.timestamp} - - ); - } else if (prevUrl) { - // modified - return ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_updated", { senderName })} - subtitle={joinCopy} - > - {this.props.timestamp} - - ); - } else { - // assume added - return ( - } - className="mx_EventTileBubble mx_MJitsiWidgetEvent" - title={_t("timeline|m.widget|jitsi_started", { senderName })} - subtitle={joinCopy} - > - {this.props.timestamp} - - ); - } - } -} diff --git a/apps/web/src/components/views/messages/RoomAvatarEvent.tsx b/apps/web/src/components/views/messages/RoomAvatarEvent.tsx deleted file mode 100644 index c80c80c836..0000000000 --- a/apps/web/src/components/views/messages/RoomAvatarEvent.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. -Copyright 2017 Vector Creations Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; - -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { _t } from "../../../languageHandler"; -import Modal from "../../../Modal"; -import AccessibleButton from "../elements/AccessibleButton"; -import { mediaFromMxc } from "../../../customisations/Media"; -import RoomAvatar from "../avatars/RoomAvatar"; -import ImageView from "../elements/ImageView"; -interface IProps { - /* the MatrixEvent to show */ - mxEvent: MatrixEvent; -} - -export default class RoomAvatarEvent extends React.Component { - private onAvatarClick = (): void => { - const cli = MatrixClientPeg.safeGet(); - const ev = this.props.mxEvent; - const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp; - if (!httpUrl) return; - - const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const text = _t("timeline|m.room.avatar|lightbox_title", { - senderDisplayName: ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(), - roomName: room ? room.name : "", - }); - - const params = { - src: httpUrl, - name: text, - }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - }; - - public render(): React.ReactNode { - const ev = this.props.mxEvent; - const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - - if (!ev.getContent().url || ev.getContent().url.trim().length === 0) { - return
{_t("timeline|m.room.avatar|removed", { senderDisplayName })}
; - } - - const room = MatrixClientPeg.safeGet().getRoom(ev.getRoomId()); - // Provide all arguments to RoomAvatar via oobData because the avatar is historic - const oobData = { - avatarUrl: ev.getContent().url, - name: room ? room.name : "", - }; - - return ( - <> - {_t( - "timeline|m.room.avatar|changed_img", - { senderDisplayName: senderDisplayName }, - { - img: () => ( - - - - ), - }, - )} - - ); - } -} 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 c5280dc942..7c53a97736 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -22,8 +22,11 @@ import { CallStartedTileView, EncryptionEventView, HiddenBodyView, + MJitsiWidgetEventView, MKeyVerificationRequestView, + RoomAvatarEventView, TextualEventView, + ViewSourceEventView, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; @@ -35,23 +38,24 @@ import MessageEvent from "../components/views/messages/MessageEvent"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import { CallEvent } from "../components/views/messages/CallEvent"; import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile"; -import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent"; +import RoomAvatar from "../components/views/avatars/RoomAvatar"; import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore"; import { ALL_RULE_TYPES } from "../mjolnir/BanList"; import { MatrixClientPeg } from "../MatrixClientPeg"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { WidgetType } from "../widgets/WidgetType"; -import MJitsiWidgetEvent from "../components/views/messages/MJitsiWidgetEvent"; 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"; import { EncryptionEventViewModel } from "../viewmodels/room/timeline/event-tile/EncryptionEventViewModel"; +import { MJitsiWidgetEventViewModel } from "../viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel"; import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/event-tile/MKeyVerificationRequestViewModel"; +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 { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel"; @@ -125,6 +129,65 @@ 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 })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + return ; +} + +function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { + const cli = useMatrixClientContext() ?? MatrixClientPeg.safeGet(); + const vm = useCreateAutoDisposedViewModel(() => new RoomAvatarEventViewModel({ mxEvent, cli })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + const roomId = mxEvent.getRoomId(); + const room = roomId ? cli.getRoom(roomId) : null; + + return ( + ( + + )} + /> + ); +} +const RoomAvatarEventFactory: Factory = (ref, props) => ; + function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent })); return vm.isCallDeclined ? : ; @@ -135,8 +198,8 @@ export const CallStartedEventFactory: Factory = (ref, props) => { }; // These factories are exported for reference comparison against pickFactory() -export const JitsiEventFactory: Factory = (ref, props) => ; -export const JSONEventFactory: Factory = (ref, props) => ; +export const JSONEventFactory: Factory = (ref, props) => ; +export const JitsiEventFactory: Factory = (ref, props) => ; export const RoomCreateEventFactory: Factory = (_ref, props) => ; const EVENT_TILE_TYPES = new Map([ @@ -156,7 +219,7 @@ const STATE_EVENT_TILE_TYPES = new Map([ [EventType.RoomCreate, RoomCreateEventFactory], [EventType.RoomMember, TextualEventFactory], [EventType.RoomName, TextualEventFactory], - [EventType.RoomAvatar, (ref, props) => ], + [EventType.RoomAvatar, RoomAvatarEventFactory], [EventType.RoomThirdPartyInvite, TextualEventFactory], [EventType.RoomHistoryVisibility, TextualEventFactory], [EventType.RoomTopic, TextualEventFactory], diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 74a978f700..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", @@ -3462,9 +3461,7 @@ "m.poll.start": "%(senderName)s has started a poll - %(pollQuestion)s", "m.room.avatar": { "changed": "%(senderDisplayName)s changed the room avatar.", - "changed_img": "%(senderDisplayName)s changed the room avatar to ", - "lightbox_title": "%(senderDisplayName)s changed the avatar for %(roomName)s", - "removed": "%(senderDisplayName)s removed the room avatar." + "lightbox_title": "%(senderDisplayName)s changed the avatar for %(roomName)s" }, "m.room.canonical_alias": { "alt_added": { 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/MJitsiWidgetEventViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel.ts new file mode 100644 index 0000000000..d622877af4 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type JSX } from "react"; +import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { + BaseViewModel, + type MJitsiWidgetEventViewModel as MJitsiWidgetEventViewModelInterface, + type MJitsiWidgetEventViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../../../languageHandler"; +import WidgetStore, { type IApp } from "../../../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../../../stores/AsyncStore"; +import { WidgetLayoutStore } from "../../../../stores/widgets/WidgetLayoutStore"; + +export interface MJitsiWidgetEventViewModelProps { + /** + * Caller-provided client. + */ + cli: MatrixClient; + /** + * Jitsi widget state event to derive tile state from. + */ + mxEvent: MatrixEvent; + /** + * Optional timestamp element rendered in the tile footer slot. + */ + timestamp?: JSX.Element; + /** + * Widget store used to resolve the widget referenced by the state event. + */ + widgetStore?: WidgetStore; + /** + * Widget layout store used to resolve the current join prompt. + */ + widgetLayoutStore?: WidgetLayoutStore; +} + +type InternalProps = Required> & + Omit; + +/** + * ViewModel for Jitsi widget events. + */ +export class MJitsiWidgetEventViewModel + extends BaseViewModel + implements MJitsiWidgetEventViewModelInterface +{ + public constructor(props: MJitsiWidgetEventViewModelProps) { + const internalProps = { + ...props, + widgetStore: props.widgetStore ?? WidgetStore.instance, + widgetLayoutStore: props.widgetLayoutStore ?? WidgetLayoutStore.instance, + }; + + super(internalProps, MJitsiWidgetEventViewModel.computeSnapshot(internalProps)); + this.trackStoreUpdates(); + } + + public setEvent(mxEvent: MatrixEvent): void { + this.props = { ...this.props, mxEvent }; + this.updateSnapshotFromProps(); + } + + private trackStoreUpdates(): void { + const roomId = this.props.mxEvent.getRoomId(); + const room = roomId ? this.props.cli.getRoom(roomId) : null; + + this.disposables.trackListener(this.props.widgetStore, UPDATE_EVENT, (updatedRoomId?: unknown) => { + if (typeof updatedRoomId === "string" && updatedRoomId !== this.props.mxEvent.getRoomId()) return; + this.updateSnapshotFromProps(); + }); + + if (roomId) { + this.disposables.trackListener(this.props.widgetStore, roomId, () => this.updateSnapshotFromProps()); + } + + if (room) { + this.disposables.trackListener(this.props.widgetLayoutStore, WidgetLayoutStore.emissionForRoom(room), () => + this.updateSnapshotFromProps(), + ); + } + } + + private updateSnapshotFromProps(): void { + this.snapshot.merge(MJitsiWidgetEventViewModel.computeSnapshot(this.props)); + } + + private static computeSnapshot(props: InternalProps): MJitsiWidgetEventViewSnapshot { + const { mxEvent, timestamp } = props; + const roomId = mxEvent.getRoomId(); + const room = roomId ? props.cli.getRoom(roomId) : null; + + if (!room) { + return { + isVisible: false, + title: "", + subtitle: null, + timestamp, + }; + } + + const content = mxEvent.getContent<{ url?: string }>(); + const prevContent = mxEvent.getPrevContent() as { url?: string }; + const senderName = mxEvent.sender?.name || mxEvent.getSender() || ""; + const widget = MJitsiWidgetEventViewModel.getWidget(props); + let subtitle: string | null = null; + + if (content.url && widget) { + subtitle = props.widgetLayoutStore.isInContainer(room, widget, "right") + ? _t("timeline|m.widget|jitsi_join_right_prompt") + : _t("timeline|m.widget|jitsi_join_top_prompt"); + } + + if (!content.url) { + return { + isVisible: true, + title: _t("timeline|m.widget|jitsi_ended", { senderName }), + subtitle: null, + timestamp, + }; + } + + if (prevContent.url) { + return { + isVisible: true, + title: _t("timeline|m.widget|jitsi_updated", { senderName }), + subtitle, + timestamp, + }; + } + + return { + isVisible: true, + title: _t("timeline|m.widget|jitsi_started", { senderName }), + subtitle, + timestamp, + }; + } + + private static getWidget(props: InternalProps): IApp | undefined { + const roomId = props.mxEvent.getRoomId(); + const widgetId = props.mxEvent.getStateKey(); + + if (!roomId || widgetId === undefined) return undefined; + + return props.widgetStore.getRoom(roomId, true).widgets.find((widget) => widget.id === widgetId); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel.ts new file mode 100644 index 0000000000..c2a0f11d16 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; +import { + BaseViewModel, + type RoomAvatarEventViewModel as RoomAvatarEventViewModelInterface, + type RoomAvatarEventViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { mediaFromMxc } from "../../../../customisations/Media"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; +import ImageView from "../../../../components/views/elements/ImageView"; + +export interface RoomAvatarEventViewModelProps { + /** + * Caller-provided client. + */ + cli: MatrixClient; + /** + * Room avatar state event. + */ + mxEvent: MatrixEvent; +} + +/** + * ViewModel for room avatar state events. + */ +export class RoomAvatarEventViewModel + extends BaseViewModel + implements RoomAvatarEventViewModelInterface +{ + public constructor(props: RoomAvatarEventViewModelProps) { + super(props, RoomAvatarEventViewModel.computeSnapshot(props)); + } + + public setEvent(mxEvent: MatrixEvent): void { + this.props = { ...this.props, mxEvent }; + this.updateSnapshotFromProps(); + } + + public onAvatarClick = (): void => { + const avatarUrl = RoomAvatarEventViewModel.getAvatarUrl(this.props.mxEvent); + if (!avatarUrl) return; + + const httpUrl = mediaFromMxc(avatarUrl, this.props.cli).srcHttp; + if (!httpUrl) return; + + Modal.createDialog( + ImageView, + { + src: httpUrl, + name: RoomAvatarEventViewModel.computeLightboxLabel(this.props), + }, + "mx_Dialog_lightbox", + undefined, + true, + ); + }; + + private updateSnapshotFromProps(): void { + this.snapshot.merge(RoomAvatarEventViewModel.computeSnapshot(this.props)); + } + + private static computeSnapshot(props: RoomAvatarEventViewModelProps): RoomAvatarEventViewSnapshot { + const avatarUrl = RoomAvatarEventViewModel.getAvatarUrl(props.mxEvent); + const senderDisplayName = RoomAvatarEventViewModel.getSenderDisplayName(props.mxEvent); + const roomName = RoomAvatarEventViewModel.getRoomName(props); + + return { + senderDisplayName, + roomName, + avatarUrl, + lightboxLabel: RoomAvatarEventViewModel.computeLightboxLabel(props), + isRemoved: !avatarUrl, + }; + } + + private static computeLightboxLabel(props: RoomAvatarEventViewModelProps): string { + return _t("timeline|m.room.avatar|lightbox_title", { + senderDisplayName: RoomAvatarEventViewModel.getSenderDisplayName(props.mxEvent), + roomName: RoomAvatarEventViewModel.getRoomName(props), + }); + } + + private static getSenderDisplayName(mxEvent: MatrixEvent): string { + return mxEvent.sender?.name || mxEvent.getSender() || ""; + } + + private static getRoomName({ cli, mxEvent }: RoomAvatarEventViewModelProps): string { + const roomId = mxEvent.getRoomId(); + if (!roomId) return ""; + + return cli.getRoom(roomId)?.name ?? ""; + } + + private static getAvatarUrl(mxEvent: MatrixEvent): string | undefined { + const avatarUrl = mxEvent.getContent().url; + if (!avatarUrl || avatarUrl.trim().length === 0) return undefined; + + return avatarUrl; + } +} 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/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/unit-tests/events/EventTileFactory-test.ts b/apps/web/test/unit-tests/events/EventTileFactory-test.ts index f37e30667f..b8ea0af5e4 100644 --- a/apps/web/test/unit-tests/events/EventTileFactory-test.ts +++ b/apps/web/test/unit-tests/events/EventTileFactory-test.ts @@ -23,6 +23,8 @@ import { createTestClient, mkEvent } from "../../test-utils"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { ModuleApi } from "../../../src/modules/Api"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; const roomId = "!room:example.com"; @@ -41,6 +43,18 @@ function makeVerificationRequestEvent({ sender, to }: { sender: string; to: stri }); } +function makeRoomAvatarEvent(url = "mxc://example.com/avatar"): MatrixEvent { + return new MatrixEvent({ + type: EventType.RoomAvatar, + state_key: "", + room_id: roomId, + sender: "@alice:example.com", + content: { + url, + }, + }); +} + describe("pickFactory", () => { let client: MatrixClient; let room: Room; @@ -363,4 +377,40 @@ describe("renderTile", () => { expect(() => render(tile)).toThrow("Attempting to render verification request without a client context!"); }); + + it("renders room avatar events with the wrapped shared-components view", () => { + const room = new Room(roomId, client, client.getSafeUserId()); + room.name = "General"; + room.currentState.setStateEvents([ + new MatrixEvent({ + type: EventType.RoomCreate, + state_key: "", + room_id: room.roomId, + sender: client.getUserId()!, + content: { + creator: client.getUserId()!, + room_version: "9", + }, + }), + ]); + mocked(client.getRoom).mockReturnValue(room); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn().mockReturnValue(null), + } as unknown as DMRoomMap); + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + const roomAvatarEvent = makeRoomAvatarEvent(); + roomAvatarEvent.sender = { name: "Alice" } as MatrixEvent["sender"]; + + const tile = renderTile( + TimelineRenderingType.Room, + { mxEvent: roomAvatarEvent, showHiddenEvents: false }, + client, + ); + if (!tile) throw new Error("Expected a room avatar event tile"); + + render(React.createElement(MatrixClientContext.Provider, { value: client }, tile)); + + expect(screen.getByText("Alice changed the room avatar to")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Alice changed the avatar for General" })).toBeInTheDocument(); + }); }); diff --git a/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx b/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx new file mode 100644 index 0000000000..46c41d96a6 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/MJitsiWidgetEventViewModel-test.tsx @@ -0,0 +1,180 @@ +/* + * 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 { EventEmitter } from "events"; +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; + +import { MJitsiWidgetEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/MJitsiWidgetEventViewModel"; +import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; +import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore"; +import { mkEvent, stubClient } from "../../test-utils"; +import type WidgetStore from "../../../src/stores/WidgetStore"; +import type { IApp } from "../../../src/stores/WidgetStore"; + +describe("MJitsiWidgetEventViewModel", () => { + const roomId = "!room:example.com"; + const widgetId = "jitsi"; + let cli: MatrixClient; + let room: Room; + let widget: IApp; + let widgetStore: WidgetStore & EventEmitter; + let widgetLayoutStore: WidgetLayoutStore & EventEmitter; + + const createEvent = (content: { url?: string }, prevContent: { url?: string } = {}) => + mkEvent({ + event: true, + room: roomId, + user: "@alice:example.com", + skey: widgetId, + type: "im.vector.modular.widgets", + content, + prev_content: prevContent, + }); + + const createVm = ( + props: Partial[0]> = {}, + ): MJitsiWidgetEventViewModel => + new MJitsiWidgetEventViewModel({ + cli, + mxEvent: createEvent({ url: "https://jitsi.example.com/room" }), + widgetStore, + widgetLayoutStore, + ...props, + }); + + beforeEach(() => { + cli = stubClient(); + room = cli.getRoom(roomId)!; + widget = { + id: widgetId, + roomId, + type: "m.jitsi", + } as IApp; + widgetStore = Object.assign(new EventEmitter(), { + getRoom: jest.fn().mockReturnValue({ widgets: [widget] }), + }) as unknown as WidgetStore & EventEmitter; + widgetLayoutStore = Object.assign(new EventEmitter(), { + isInContainer: jest.fn().mockReturnValue(false), + }) as unknown as WidgetLayoutStore & EventEmitter; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders a started Jitsi event with the top join prompt", () => { + const vm = createVm(); + + expect(vm.getSnapshot()).toMatchObject({ + isVisible: true, + title: "Video conference started by @alice:example.com", + subtitle: "Join the conference at the top of this room", + }); + }); + + it("uses the right-panel join prompt when the widget is in the right container", () => { + jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true); + + const vm = createVm(); + + expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right"); + }); + + it("omits the join prompt when the widget no longer exists", () => { + jest.mocked(widgetStore.getRoom).mockReturnValue({ widgets: [] }); + + const vm = createVm(); + + expect(vm.getSnapshot()).toMatchObject({ + isVisible: true, + title: "Video conference started by @alice:example.com", + subtitle: null, + }); + }); + + it("renders an updated Jitsi event", () => { + const vm = createVm({ + mxEvent: createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" }), + }); + + expect(vm.getSnapshot()).toMatchObject({ + isVisible: true, + title: "Video conference updated by @alice:example.com", + subtitle: "Join the conference at the top of this room", + }); + }); + + it("renders an ended Jitsi event without a join prompt", () => { + const vm = createVm({ + mxEvent: createEvent({}, { url: "https://old.example.com/room" }), + }); + + expect(vm.getSnapshot()).toMatchObject({ + isVisible: true, + title: "Video conference ended by @alice:example.com", + subtitle: null, + }); + }); + + it("hides the event when the room is unavailable", () => { + jest.spyOn(cli, "getRoom").mockReturnValue(null); + + const vm = createVm(); + + expect(vm.getSnapshot()).toMatchObject({ + isVisible: false, + title: "", + subtitle: null, + }); + }); + + it("updates the snapshot when the event changes", () => { + const vm = createVm(); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(createEvent({ url: "https://jitsi.example.com/room" }, { url: "https://old.example.com/room" })); + + expect(vm.getSnapshot().title).toBe("Video conference updated by @alice:example.com"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("does not emit updates when setEvent receives the current event", () => { + const mxEvent = createEvent({ url: "https://jitsi.example.com/room" }); + const listener = jest.fn(); + const vm = createVm({ mxEvent }); + + vm.subscribe(listener); + vm.setEvent(mxEvent); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("updates when widget stores emit for the room", () => { + const vm = createVm(); + const listener = jest.fn(); + vm.subscribe(listener); + jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true); + + widgetStore.emit(UPDATE_EVENT, roomId); + + expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("updates when the widget layout store emits for the room", () => { + const vm = createVm(); + const listener = jest.fn(); + vm.subscribe(listener); + jest.mocked(widgetLayoutStore.isInContainer).mockReturnValue(true); + + widgetLayoutStore.emit(WidgetLayoutStore.emissionForRoom(room)); + + expect(vm.getSnapshot().subtitle).toBe("Join the conference from the room information card on the right"); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/RoomAvatarEventViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/RoomAvatarEventViewModel-test.ts new file mode 100644 index 0000000000..9735a4c0aa --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/RoomAvatarEventViewModel-test.ts @@ -0,0 +1,138 @@ +/* + * 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 { EventType, type MatrixClient, MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../src/Modal"; +import { RoomAvatarEventViewModel } from "../../../src/viewmodels/room/timeline/event-tile/RoomAvatarEventViewModel"; + +describe("RoomAvatarEventViewModel", () => { + const roomId = "!room:example.org"; + let cli: MatrixClient; + let room: Room; + let mxcUrlToHttp: jest.Mock; + + beforeEach(() => { + mxcUrlToHttp = jest.fn().mockReturnValue("https://example.org/_matrix/media/v3/download/avatar"); + room = { + name: "General", + } as unknown as Room; + cli = { + getRoom: jest.fn().mockReturnValue(room), + mxcUrlToHttp, + } as unknown as MatrixClient; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createEvent = ( + url?: string, + sender = "@alice:example.org", + eventRoomId: string | undefined = roomId, + ): MatrixEvent => + new MatrixEvent({ + type: EventType.RoomAvatar, + room_id: eventRoomId, + state_key: "", + sender, + content: { + url, + }, + }); + + it("extracts room avatar event details", () => { + const mxEvent = createEvent("mxc://example.org/avatar"); + mxEvent.sender = { name: "Alice" } as MatrixEvent["sender"]; + + const vm = new RoomAvatarEventViewModel({ cli, mxEvent }); + + expect(vm.getSnapshot()).toEqual({ + senderDisplayName: "Alice", + roomName: "General", + avatarUrl: "mxc://example.org/avatar", + lightboxLabel: "Alice changed the avatar for General", + isRemoved: false, + }); + }); + + it("falls back to the sender ID when no sender member is available", () => { + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") }); + + expect(vm.getSnapshot().senderDisplayName).toBe("@alice:example.org"); + }); + + it("marks the event as removed when no avatar URL is present", () => { + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("") }); + + expect(vm.getSnapshot()).toMatchObject({ + avatarUrl: undefined, + isRemoved: true, + }); + }); + + it("updates the snapshot when the event changes", () => { + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(createEvent("mxc://example.org/next", "@bob:example.org")); + + expect(vm.getSnapshot()).toMatchObject({ + senderDisplayName: "@bob:example.org", + avatarUrl: "mxc://example.org/next", + isRemoved: false, + }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("does not emit when the event-derived snapshot is unchanged", () => { + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setEvent(createEvent("mxc://example.org/avatar")); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("opens the room avatar in the lightbox", () => { + const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any); + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("mxc://example.org/avatar") }); + + vm.onAvatarClick(); + + expect(mxcUrlToHttp).toHaveBeenCalledWith( + "mxc://example.org/avatar", + undefined, + undefined, + undefined, + false, + true, + ); + expect(dialogSpy).toHaveBeenCalledWith( + expect.any(Function), + { + src: "https://example.org/_matrix/media/v3/download/avatar", + name: "@alice:example.org changed the avatar for General", + }, + "mx_Dialog_lightbox", + undefined, + true, + ); + }); + + it("does not open the lightbox when the event has no avatar URL", () => { + const dialogSpy = jest.spyOn(Modal, "createDialog").mockReturnValue({ close: jest.fn() } as any); + const vm = new RoomAvatarEventViewModel({ cli, mxEvent: createEvent("") }); + + vm.onAvatarClick(); + + expect(dialogSpy).not.toHaveBeenCalled(); + }); +}); 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/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png new file mode 100644 index 0000000000..ce148ddc96 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/ended-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..17fe39394f Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png new file mode 100644 index 0000000000..80ef9c8ef9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/started-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png new file mode 100644 index 0000000000..6aa12cc0f7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/updated-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png new file mode 100644 index 0000000000..f7a924cbe3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx/with-timestamp-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/changed-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/changed-auto.png new file mode 100644 index 0000000000..54413bae30 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/changed-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/removed-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/removed-auto.png new file mode 100644 index 0000000000..3951d681b8 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx/removed-auto.png differ 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 771e1a86ac..9cd0605fed 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" }, @@ -226,6 +229,10 @@ "m.file": { "error_invalid": "Invalid file" }, + "m.room.avatar": { + "changed_img": "%(senderDisplayName)s changed the room avatar to", + "removed": "%(senderDisplayName)s removed the room avatar." + }, "m.room.encryption": { "disable_attempt": "Ignored attempt to disable encryption", "disabled": "Encryption not enabled", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index fa52da1a02..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"; @@ -41,8 +42,10 @@ export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile"; export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView"; export * from "./room/timeline/event-tile/call"; export * from "./room/timeline/event-tile/EventTileView/EventTileBubble"; +export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView"; export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView"; export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge"; +export * from "./room/timeline/event-tile/EventTileView/RoomAvatarEventView"; export * from "./room/timeline/event-tile/EventTileView/TextualEventView"; export * from "./room/timeline/event-tile/body/AudioPlayerView"; export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx new file mode 100644 index 0000000000..1fa6b3d5db --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.stories.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { MJitsiWidgetEventView, type MJitsiWidgetEventViewSnapshot } from "./MJitsiWidgetEventView"; + +type MJitsiWidgetEventViewProps = MJitsiWidgetEventViewSnapshot & { + className?: string; +}; + +const MJitsiWidgetEventViewWrapperImpl = ({ + className, + ...snapshot +}: MJitsiWidgetEventViewProps): JSX.Element | null => { + const vm = useMockedViewModel(snapshot, {}); + + return ; +}; + +const MJitsiWidgetEventViewWrapper = withViewDocs(MJitsiWidgetEventViewWrapperImpl, MJitsiWidgetEventView); + +const meta = { + title: "Timeline/Timeline Event/MJitsiWidgetEventView", + component: MJitsiWidgetEventViewWrapper, + tags: ["autodocs"], + args: { + isVisible: true, + title: "Video conference started by Alice", + subtitle: "Join the conference at the top of this room", + className: "", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Started: Story = {}; + +export const Updated: Story = { + args: { + title: "Video conference updated by Alice", + subtitle: "Join the conference from the room information card on the right", + }, +}; + +export const Ended: Story = { + args: { + title: "Video conference ended by Alice", + subtitle: null, + }, +}; + +export const Hidden: Story = { + args: { + isVisible: false, + title: "", + subtitle: null, + }, +}; + +export const WithTimestamp: Story = { + args: { + timestamp: 14:56, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx new file mode 100644 index 0000000000..7879ba7942 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import React from "react"; +import { describe, expect, it } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { MJitsiWidgetEventView } from "./MJitsiWidgetEventView"; +import * as stories from "./MJitsiWidgetEventView.stories"; + +const { Started, Updated, Ended, Hidden, WithTimestamp } = composeStories(stories); + +describe("MJitsiWidgetEventView", () => { + it("renders the Started story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Video conference started by Alice")).toBeInTheDocument(); + expect(screen.getByText("Join the conference at the top of this room")).toBeInTheDocument(); + }); + + it("renders the Updated story", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Video conference updated by Alice")).toBeInTheDocument(); + expect(screen.getByText("Join the conference from the room information card on the right")).toBeInTheDocument(); + }); + + it("renders the Ended story without a subtitle", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("Video conference ended by Alice")).toBeInTheDocument(); + expect(screen.queryByText(/Join the conference/)).not.toBeInTheDocument(); + }); + + it("renders nothing when hidden", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.queryByText(/Video conference/)).not.toBeInTheDocument(); + }); + + it("renders a timestamp", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + expect(screen.getByText("14:56")).toBeInTheDocument(); + }); + + it("applies a custom className to the root element", () => { + const vm = new MockViewModel({ + isVisible: true, + title: "Video conference started by Alice", + subtitle: null, + }); + const { container } = render(); + + expect(container.firstChild).toHaveClass("custom-jitsi"); + }); + + it("forwards the provided ref to the root element", () => { + const ref = React.createRef() as React.RefObject; + const vm = new MockViewModel({ + isVisible: true, + title: "Video conference started by Alice", + subtitle: null, + }); + + render(); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveTextContent("Video conference started by Alice"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx new file mode 100644 index 0000000000..a6fc338fb4 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/MJitsiWidgetEventView.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import { EventTileBubble } from "../EventTileBubble"; + +export interface MJitsiWidgetEventViewSnapshot { + /** + * Whether the event has enough context to render. + */ + isVisible: boolean; + /** + * Main title text for the Jitsi widget event. + */ + title: string; + /** + * Optional join prompt shown below the title. + */ + subtitle: string | null; + /** + * Optional timestamp element rendered in the EventTileBubble footer slot. + */ + timestamp?: JSX.Element; +} + +export type MJitsiWidgetEventViewModel = ViewModel; + +export interface MJitsiWidgetEventViewProps { + /** + * ViewModel providing the current Jitsi widget event snapshot. + */ + vm: MJitsiWidgetEventViewModel; + /** + * Optional CSS classes passed through to EventTileBubble. + */ + className?: string; + /** + * Optional Ref forwarded to the root DOM element. + */ + ref?: React.RefObject; +} + +/** + * Renders a timeline bubble describing a Jitsi widget state event. + */ +export function MJitsiWidgetEventView({ + vm, + className, + ref, +}: Readonly): JSX.Element | null { + const { isVisible, title, subtitle, timestamp } = useViewModel(vm); + + if (!isVisible) return null; + + return ( + } + className={className} + title={title} + subtitle={subtitle || undefined} + ref={ref} + > + {timestamp} + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap new file mode 100644 index 0000000000..7644d05473 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/__snapshots__/MJitsiWidgetEventView.test.tsx.snap @@ -0,0 +1,125 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MJitsiWidgetEventView > renders a timestamp 1`] = ` +
+
+ + + +
+ Video conference started by Alice +
+
+ Join the conference at the top of this room +
+ + 14:56 + +
+
+`; + +exports[`MJitsiWidgetEventView > renders nothing when hidden 1`] = `
`; + +exports[`MJitsiWidgetEventView > renders the Ended story without a subtitle 1`] = ` +
+
+ + + +
+ Video conference ended by Alice +
+
+
+`; + +exports[`MJitsiWidgetEventView > renders the Started story 1`] = ` +
+
+ + + +
+ Video conference started by Alice +
+
+ Join the conference at the top of this room +
+
+
+`; + +exports[`MJitsiWidgetEventView > renders the Updated story 1`] = ` +
+
+ + + +
+ Video conference updated by Alice +
+
+ Join the conference from the room information card on the right +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts new file mode 100644 index 0000000000..5566a78512 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/MJitsiWidgetEventView/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { + MJitsiWidgetEventView, + type MJitsiWidgetEventViewProps, + type MJitsiWidgetEventViewSnapshot, + type MJitsiWidgetEventViewModel, +} from "./MJitsiWidgetEventView"; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.module.css b/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.module.css new file mode 100644 index 0000000000..24fcda18d0 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.module.css @@ -0,0 +1,31 @@ +/* + * 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. + */ + +.textualEvent { + font-size: var(--cpd-font-size-body-sm); + line-height: normal; + overflow-y: hidden; + color: var(--cpd-color-text-secondary); +} + +.textualEvent[data-event-layout="irc"] { + padding: 1px 0; + display: inline-block; + line-height: 1.125rem; +} + +.avatarButton { + display: inline; + position: relative; + top: 3px; + margin-inline-start: 0.25em; + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx new file mode 100644 index 0000000000..121ce86b0f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/RoomAvatarEventView/RoomAvatarEventView.stories.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { + RoomAvatarEventView, + type RoomAvatarEventViewActions, + type RoomAvatarEventViewSnapshot, +} from "./RoomAvatarEventView"; + +type RoomAvatarEventViewStoryProps = RoomAvatarEventViewSnapshot & + RoomAvatarEventViewActions & { + className?: string; + }; + +const RoomAvatarEventViewWrapperImpl = ({ + onAvatarClick, + className, + ...snapshot +}: RoomAvatarEventViewStoryProps): JSX.Element => { + const vm = useMockedViewModel(snapshot, { onAvatarClick }); + + return ( + ( +